diff --git a/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt b/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt index a2baa9126b..c3cfe566f3 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/UserTokensRepository.kt @@ -2,6 +2,7 @@ package com.tangem.tap.domain.tokens import com.tangem.blockchain.common.derivation.DerivationStyle import com.tangem.common.core.TangemSdkError +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechService import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -106,7 +107,8 @@ class UserTokensRepository( return runCatching { tangemTechApi.getUserTokens(userWalletId) } .fold( onSuccess = { response -> - response.tokens + response.getOrThrow() + .tokens .mapNotNull(Currency.Companion::fromTokenResponse) .also { storageService.saveUserTokens(userWalletId, it.toUserTokensResponse()) } .distinct() diff --git a/app/src/main/java/com/tangem/tap/features/wallet/data/WalletRepositoryImpl.kt b/app/src/main/java/com/tangem/tap/features/wallet/data/WalletRepositoryImpl.kt index e610e03389..e0b6ce6972 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/data/WalletRepositoryImpl.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/data/WalletRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.tangem.tap.features.wallet.data +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse import com.tangem.tap.features.wallet.domain.WalletRepository @@ -18,6 +19,6 @@ class WalletRepositoryImpl( ) : WalletRepository { override suspend fun getCurrencyList(): CurrenciesResponse = withContext(dispatchers.io) { - tangemTechApi.getCurrencyList() + tangemTechApi.getCurrencyList().getOrThrow() } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt new file mode 100644 index 0000000000..0348dba0e3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponse.kt @@ -0,0 +1,40 @@ +package com.tangem.datasource.api.common.response + +/** + * Represents the possible responses from an API request. + * + * @param T The type of the data that is expected in a successful response. + */ +sealed class ApiResponse { + + /** + * Represents a successful response from the API. + * + * @property data The data returned by the API. + */ + data class Success(val data: T) : ApiResponse() + + /** + * Represents an error response or failure from the API. + * + * @property cause The cause of the error. + */ + data class Error(val cause: ApiResponseError) : ApiResponse() +} + +/** + * Wraps data in a [ApiResponse.Success] instance. + * + * @param data The data to wrap. + * @return A [ApiResponse.Success] instance containing the provided data. + */ +internal fun apiSuccess(data: T): ApiResponse = ApiResponse.Success(data) + +/** + * Wraps an [ApiResponseError] in a [ApiResponse.Error] instance. + * + * @param cause The error to wrap. + * @return A [ApiResponse.Error] instance containing the provided error. + */ +@Suppress("UNCHECKED_CAST") +internal fun apiError(cause: ApiResponseError): ApiResponse = ApiResponse.Error(cause) as ApiResponse \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt new file mode 100644 index 0000000000..885b52ca28 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.api.common.response + +import retrofit2.Call +import retrofit2.CallAdapter +import java.lang.reflect.Type + +internal class ApiResponseCallAdapter( + private val resultType: Type, +) : CallAdapter>> { + + override fun responseType(): Type = resultType + + override fun adapt(call: Call): Call> { + return ApiResponseCallDelegate(call) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt new file mode 100644 index 0000000000..11eca2e0d6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt @@ -0,0 +1,29 @@ +package com.tangem.datasource.api.common.response + +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +internal class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() { + + override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? { + if (getRawType(returnType) != Call::class.java) { + return null + } + + val callType = getParameterUpperBound(0, returnType as ParameterizedType) + if (getRawType(callType) != ApiResponse::class.java) { + return null + } + + val resultType = getParameterUpperBound(0, callType as ParameterizedType) + return ApiResponseCallAdapter(resultType) + } + + companion object { + + fun create() = ApiResponseCallAdapterFactory() + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt new file mode 100644 index 0000000000..b69ef4e355 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.api.common.response + +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +internal class ApiResponseCallDelegate( + private val wrappedCall: Call, +) : Call> { + + override fun enqueue(callback: Callback>) { + wrappedCall.enqueue(ApiResponseCallback(callback)) + } + + override fun execute(): Response> = throw NotImplementedError() + override fun clone(): Call> = ApiResponseCallDelegate(wrappedCall.clone()) + override fun request(): Request = wrappedCall.request() + override fun timeout(): Timeout = wrappedCall.timeout() + override fun isExecuted(): Boolean = wrappedCall.isExecuted + override fun isCanceled(): Boolean = wrappedCall.isCanceled + override fun cancel() { wrappedCall.cancel() } + + private inner class ApiResponseCallback( + private val responseCallback: Callback>, + ) : Callback { + + override fun onResponse(call: Call, response: Response) { + val safeResponse = response.toSafeApiResponse() + + responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse)) + } + + override fun onFailure(call: Call, t: Throwable) { + val e = if (t.isNetworkException()) { + ApiResponseError.NetworkException + } else { + ApiResponseError.UnknownException(t) + } + val safeResponse = apiError(e) + + responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse)) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt new file mode 100644 index 0000000000..5e88155058 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -0,0 +1,80 @@ +package com.tangem.datasource.api.common.response + +/** + * Represents the possible errors that can occur during an API request. + */ +sealed class ApiResponseError : Exception() { + + /** + * Represents an HTTP exception, which typically occurs when the server responds + * with a non-2xx HTTP status code. + * + * @property code The HTTP status code. + * @property message A human-readable message describing the error. + */ + data class HttpException(val code: Code, override val message: String?) : ApiResponseError() { + + // region Error Codes + enum class Code(val code: Int) { + // 4xx Server Errors + BAD_REQUEST(code = 400), + UNAUTHORIZED(code = 401), + PAYMENT_REQUIRED(code = 402), + FORBIDDEN(code = 403), + NOT_FOUND(code = 404), + METHOD_NOT_ALLOWED(code = 405), + NOT_ACCEPTABLE(code = 406), + PROXY_AUTHENTICATION_REQUIRED(code = 407), + REQUEST_TIMEOUT(code = 408), + CONFLICT(code = 409), + GONE(code = 410), + LENGTH_REQUIRED(code = 411), + PRECONDITION_FAILED(code = 412), + PAYLOAD_TOO_LARGE(code = 413), + URI_TOO_LONG(code = 414), + UNSUPPORTED_MEDIA_TYPE(code = 415), + RANGE_NOT_SATISFIABLE(code = 416), + EXPECTATION_FAILED(code = 417), + IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324 + UNPROCESSABLE_ENTITY(code = 422), + LOCKED(code = 423), + FAILED_DEPENDENCY(code = 424), + TOO_EARLY(code = 425), + UPGRADE_REQUIRED(code = 426), + PRECONDITION_REQUIRED(code = 428), + TOO_MANY_REQUESTS(code = 429), + REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431), + UNAVAILABLE_FOR_LEGAL_REASONS(code = 451), + // 5xx Server Errors + INTERNAL_SERVER_ERROR(code = 500), + NOT_IMPLEMENTED(code = 501), + BAD_GATEWAY(code = 502), + SERVICE_UNAVAILABLE(code = 503), + GATEWAY_TIMEOUT(code = 504), + HTTP_VERSION_NOT_SUPPORTED(code = 505), + VARIANT_ALSO_NEGOTIATES(code = 506), + INSUFFICIENT_STORAGE(code = 507), + LOOP_DETECTED(code = 508), + NOT_EXTENDED(code = 510), + NETWORK_AUTHENTICATION_REQUIRED(code = 511), + ; + + override fun toString(): String = "$code - $name" + + companion object { + val values = values() + } + } + // endregion Error Codes + } + + /** Represents a network error, typically when there's no connectivity. */ + object NetworkException : ApiResponseError() + + /** + * Represents an unexpected exception that doesn't fall into one of the other categories. + * + * @property cause The exception that caused this error. + */ + data class UnknownException(override val cause: Throwable) : ApiResponseError() +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt new file mode 100644 index 0000000000..256c45afe2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseExt.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.api.common.response + +fun ApiResponse.getOrThrow(): T = when (this) { + is ApiResponse.Error -> throw cause + is ApiResponse.Success -> data +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt new file mode 100644 index 0000000000..0ddb0ffa36 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.api.common.response + +import retrofit2.Response +import java.net.ConnectException +import java.net.UnknownHostException +import javax.net.ssl.SSLHandshakeException + +internal fun Response.toSafeApiResponse(): ApiResponse { + val body = body() + + return if (isSuccessful && body != null) { + apiSuccess(body) + } else { + val code = ApiResponseError.HttpException.Code.values + .firstOrNull { it.code == code() } + val e = if (code == null) { + ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) + } else { + ApiResponseError.HttpException(code, message()) + } + + apiError(e) + } +} + +internal fun Throwable.isNetworkException(): Boolean = when (this) { + is ConnectException, + is UnknownHostException, + is SSLHandshakeException, + -> true + else -> false +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 0a609246ac..be16a55885 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.api.tangemTech +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.tangemTech.models.* import retrofit2.http.* @@ -25,13 +26,13 @@ interface TangemTechApi { suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse @GET("currencies") - suspend fun getCurrencyList(): CurrenciesResponse + suspend fun getCurrencyList(): ApiResponse @GET("geo") suspend fun getUserCountryCode(): GeoResponse @GET("user-tokens/{user-id}") - suspend fun getUserTokens(@Path(value = "user-id") userId: String): UserTokensResponse + suspend fun getUserTokens(@Path(value = "user-id") userId: String): ApiResponse @PUT("user-tokens/{user-id}") suspend fun saveUserTokens(@Path(value = "user-id") userId: String, @Body userTokens: UserTokensResponse) @@ -66,5 +67,5 @@ interface TangemTechApi { @Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String, @Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt", - ): QuotesResponse + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechService.kt index 50f11aa961..cf51d43c58 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechService.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechService.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.MoshiConverter +import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory import com.tangem.datasource.utils.RequestHeader import com.tangem.datasource.utils.RequestHeader.AuthenticationHeader import com.tangem.datasource.utils.RequestHeader.CacheControlHeader @@ -29,6 +30,7 @@ object TangemTechService { val headers = mutableListOf(CacheControlHeader).apply { header?.let(::add) } return Retrofit.Builder() .addConverterFactory(MoshiConverter.networkMoshiConverter) + .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) .baseUrl(TANGEM_TECH_BASE_URL) .client( OkHttpClient.Builder() diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index e68a69e06e..3a9580d2d0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.squareup.moshi.Moshi +import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory import com.tangem.datasource.api.paymentology.PaymentologyApi import com.tangem.datasource.api.promotion.PromotionApi import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -27,6 +28,7 @@ class NetworkModule { fun provideTangemTechApi(@NetworkMoshi moshi: Moshi): TangemTechApi { return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) .baseUrl(PROD_TANGEM_TECH_BASE_URL) .client( OkHttpClient.Builder() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/cache/CacheKeysStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/cache/CacheKeysStore.kt index 962c09661a..20e96ef798 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/cache/CacheKeysStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/cache/CacheKeysStore.kt @@ -10,5 +10,7 @@ interface CacheKeysStore { suspend fun remove(key: String) + suspend fun remove(keys: Collection) + suspend fun clear() } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/FileDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/FileDataStore.kt index 44038e9880..768a0e4c6a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/FileDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/FileDataStore.kt @@ -65,6 +65,13 @@ internal class FileDataStore( writeTrigger.trigger() } + override suspend fun remove(keys: Collection) { + val e = NotImplementedError("`remove(keys)` function not implemented for `FileDataStore`") + Timber.e(e) + + throw e + } + override suspend fun clear() { val e = NotImplementedError("`clear()` function not implemented for `FileDataStore`") Timber.e(e) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeDataStore.kt index 1a295689db..77aa987353 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeDataStore.kt @@ -12,11 +12,9 @@ internal class RuntimeDataStore : StringKeyDataStore { } override fun get(key: String): Flow { - return store - .map { value -> - value?.get(key) - } - .filterNotNull() + return store.mapNotNull { value -> + value?.get(key) + } } override fun getAll(): Flow> { @@ -42,20 +40,24 @@ internal class RuntimeDataStore : StringKeyDataStore { } override suspend fun store(values: Map) { - updateValue { value -> - values.forEach { (key, item) -> - value[key] = item - } + updateValue { storedValue -> + storedValue.putAll(values) - value + storedValue } } override suspend fun remove(key: String) { - updateValue { value -> - value.remove(key) + updateValue { storedValue -> + storedValue.remove(key) - value + storedValue + } + } + + override suspend fun remove(keys: Collection) { + updateValue { value -> + HashMap(value.filterKeys { it !in keys }) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/SharedPreferencesDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/SharedPreferencesDataStore.kt index 3924bc0104..bf987439b8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/SharedPreferencesDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/SharedPreferencesDataStore.kt @@ -62,6 +62,16 @@ internal abstract class SharedPreferencesDataStore( writeTrigger.trigger() } + override suspend fun remove(keys: Collection) { + sharedPreferences.edit { + keys.forEach { key -> + remove(key) + } + } + + writeTrigger.trigger() + } + override suspend fun clear() { sharedPreferences.edit { clear() } writeTrigger.trigger() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/DataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/DataStore.kt index edc3a4bbd8..828b0cb7b5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/DataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/DataStore.kt @@ -18,5 +18,7 @@ internal interface DataStore { suspend fun remove(key: Key) + suspend fun remove(keys: Collection) + suspend fun clear() } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStoreDecorator.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStoreDecorator.kt index 2e79bda3fd..d897ab9e7a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStoreDecorator.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/core/StringKeyDataStoreDecorator.kt @@ -38,6 +38,10 @@ internal abstract class StringKeyDataStoreDecorator( wrappedDataStore.remove(provideStringKey(key)) } + override suspend fun remove(keys: Collection) { + wrappedDataStore.remove(keys.map(::provideStringKey)) + } + override suspend fun clear() { wrappedDataStore.clear() } diff --git a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt index a20bd2309b..91abb1d2a3 100644 --- a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt +++ b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt @@ -1,6 +1,7 @@ package com.tangem.data.appcurrency import com.tangem.data.appcurrency.utils.AppCurrencyConverter +import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse @@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.joda.time.Duration -import timber.log.Timber internal class DefaultAppCurrencyRepository( private val tangemTechApi: TangemTechApi, @@ -80,17 +80,15 @@ internal class DefaultAppCurrencyRepository( } private suspend fun fetchAvailableCurrencies() { - try { - val response = tangemTechApi.getCurrencyList() + val response = safeApiCall( + call = { tangemTechApi.getCurrencyList().bind() }, + onError = { + cacheRegistry.invalidate(AVAILABLE_CURRENCIES_CACHE_KEY) + getDefaultCurrenciesResponse() + }, + ) - availableAppCurrenciesStore.store(response) - } catch (e: Throwable) { - Timber.e(e, "Unable to fetch available currencies") - - availableAppCurrenciesStore.store(getDefaultCurrenciesResponse()) - - throw e - } + availableAppCurrenciesStore.store(response) } private fun getDefaultCurrenciesResponse(): CurrenciesResponse = CurrenciesResponse( diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index eee47186e3..f1a67e77a3 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.jodatime) implementation(deps.timber) + implementation(deps.arrow.core) implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt new file mode 100644 index 0000000000..2d29e30093 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt @@ -0,0 +1,47 @@ +package com.tangem.data.common.api + +import arrow.core.raise.Raise +import arrow.core.raise.recover +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import timber.log.Timber + +/** + * A wrapper around the [Raise] interface specific for [ApiResponseError]. It provides utility functions to + * operate on [ApiResponse] instances. + * + * @property raise A [Raise] instance for raising [ApiResponseError]. + */ +@JvmInline +value class ApiResponseRaise( + private val raise: Raise, +) : Raise by raise { + + /** + * Binds the given [ApiResponse] to its underlying value or raises an error. + * + * @return The underlying data of the response if it's successful. + */ + fun ApiResponse.bind(): T = when (this) { + is ApiResponse.Success -> data + is ApiResponse.Error -> raise.raise(cause) + } +} + +/** + * Attempts to execute an API call safely, providing error handling. + * + * @param call The API call block to execute. + * @param onError A function to handle errors and return a fallback value of type [T]. + * + * @return The result of the API call or the fallback value provided by [onError] if an error occurs. + */ +inline fun safeApiCall(call: ApiResponseRaise.() -> T, onError: (ApiResponseError) -> T): T { + return recover( + block = { call(ApiResponseRaise(raise = this)) }, + recover = { + Timber.w(it, "Unable to perform safe API call") + onError(it) + }, + ) +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt index fb698486ad..1486cab448 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/CacheRegistry.kt @@ -24,6 +24,15 @@ interface CacheRegistry { */ suspend fun invalidate(key: String) + /** + * Invalidates cache keys in registry. + * + * If the key doesn't exist, or it's already invalidated, this method doesn't have any effect. + * + * @param keys cache keys. + */ + suspend fun invalidate(keys: Collection) + /** * Invalidates all cache keys in the registry. * diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt index 6c06ce7b88..57f6e0e76f 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/DefaultCacheRegistry.kt @@ -23,6 +23,11 @@ internal class DefaultCacheRegistry( cacheKeysStore.remove(key) } + override suspend fun invalidate(keys: Collection) { + Timber.d("Invalidate cache keys: $keys") + cacheKeysStore.remove(keys) + } + override suspend fun invalidateAll() { Timber.d("Invalidate all cache keys") cacheKeysStore.clear() @@ -32,14 +37,14 @@ internal class DefaultCacheRegistry( key: String, skipCache: Boolean, expireIn: Duration, - action: suspend () -> Unit, + block: suspend () -> Unit, ) { val isExpired = isExpired(key) || skipCache if (!isExpired) return try { Timber.d("Invoke the action associated with the cache key: $key") - action() + block() } catch (e: Throwable) { Timber.w(e, "The action related to the cache key has failed: $key") throw e diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 0967a4a262..cbe836e1cd 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -1,8 +1,10 @@ package com.tangem.data.tokens.repository import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.tokens.utils.* +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.token.UserMarketCoinsStore @@ -21,10 +23,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import retrofit2.HttpException import timber.log.Timber -import java.net.ConnectException -import java.net.UnknownHostException internal class DefaultCurrenciesRepository( private val tangemTechApi: TangemTechApi, @@ -259,14 +258,14 @@ internal class DefaultCurrenciesRepository( private suspend fun fetchTokens(userWallet: UserWallet) { val userWalletId = userWallet.walletId - val response = try { - with(tangemTechApi.getUserTokens(userWalletId.stringValue)) { - // The response may contain repeated tokens - copy(tokens = tokens.distinct()) - } - } catch (e: Throwable) { - handleFetchTokensError(userWallet, e) - } + val response = safeApiCall( + call = { + tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let { + it.copy(tokens = it.tokens.distinct()) + } + }, + onError = { handleFetchTokensError(userWallet, it) }, + ) userTokensStore.store(userWallet.walletId, response) fetchUserMarketCoinsByIds(userWalletId, response) @@ -288,7 +287,7 @@ internal class DefaultCurrenciesRepository( } } - private suspend fun handleFetchTokensError(userWallet: UserWallet, throwable: Throwable): UserTokensResponse { + private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse { val userWalletId = userWallet.walletId val response = userTokensStore.getSyncOrNull(userWalletId) ?: userTokensResponseFactory.createUserTokensResponse( @@ -297,27 +296,12 @@ internal class DefaultCurrenciesRepository( isSortedByBalance = false, ) - when (throwable) { - is ConnectException, - is UnknownHostException, - -> { - Timber.e("Unable to fetch currencies due to lack of internet connection") - } - is HttpException -> { - if (throwable.code() == NOT_FOUND_HTTP_CODE) { - Timber.w( - throwable, - "Requested currencies could not be found in the remote store for: $userWalletId", - ) + if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) { + Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId") - tangemTechApi.saveUserTokens(userWalletId.stringValue, response) - } else { - Timber.e(throwable, "Unable to fetch currencies for: $userWalletId") - } - } - else -> { - throw throwable - } + tangemTechApi.saveUserTokens(userWalletId.stringValue, response) + } else { + cacheRegistry.invalidate(getTokensCacheKey(userWalletId)) } return response diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt index f0fc985d25..e63ea16071 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt @@ -1,5 +1,6 @@ package com.tangem.data.tokens.repository +import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.tokens.utils.QuotesConverter import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -12,7 +13,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import timber.log.Timber internal class DefaultQuotesRepository( private val tangemTechApi: TangemTechApi, @@ -72,15 +72,20 @@ internal class DefaultQuotesRepository( } private suspend fun fetchQuotes(rawCurrenciesIds: Set, appCurrencyId: String) { - val response = try { - val coinIds = rawCurrenciesIds.joinToString(separator = ",") - tangemTechApi.getQuotes(appCurrencyId, coinIds) - } catch (e: Throwable) { - Timber.e(e, "Unable to fetch quotes for: $rawCurrenciesIds") - throw e - } + val response = safeApiCall( + call = { + val coinIds = rawCurrenciesIds.joinToString(separator = ",") + tangemTechApi.getQuotes(appCurrencyId, coinIds).bind() + }, + onError = { + cacheRegistry.invalidate(rawCurrenciesIds.map(::getQuoteCacheKey)) + null + }, + ) - quotesStore.store(response) + if (response != null) { + quotesStore.store(response) + } } private suspend fun filterExpiredCurrenciesIds(