Updated on 2026-08-14
This commit is contained in:
parent
f3f29813e0
commit
b74e2b9168
25 changed files with 405 additions and 72 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T : Any> {
|
||||
|
||||
/**
|
||||
* Represents a successful response from the API.
|
||||
*
|
||||
* @property data The data returned by the API.
|
||||
*/
|
||||
data class Success<T : Any>(val data: T) : ApiResponse<T>()
|
||||
|
||||
/**
|
||||
* Represents an error response or failure from the API.
|
||||
*
|
||||
* @property cause The cause of the error.
|
||||
*/
|
||||
data class Error(val cause: ApiResponseError) : ApiResponse<Nothing>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps data in a [ApiResponse.Success] instance.
|
||||
*
|
||||
* @param data The data to wrap.
|
||||
* @return A [ApiResponse.Success] instance containing the provided data.
|
||||
*/
|
||||
internal fun <T : Any> apiSuccess(data: T): ApiResponse<T> = 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 <T : Any> apiError(cause: ApiResponseError): ApiResponse<T> = ApiResponse.Error(cause) as ApiResponse<T>
|
||||
|
|
@ -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<Type, Call<ApiResponse<Type>>> {
|
||||
|
||||
override fun responseType(): Type = resultType
|
||||
|
||||
override fun adapt(call: Call<Type>): Call<ApiResponse<Type>> {
|
||||
return ApiResponseCallDelegate(call)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<out Annotation>, 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T : Any>(
|
||||
private val wrappedCall: Call<T>,
|
||||
) : Call<ApiResponse<T>> {
|
||||
|
||||
override fun enqueue(callback: Callback<ApiResponse<T>>) {
|
||||
wrappedCall.enqueue(ApiResponseCallback(callback))
|
||||
}
|
||||
|
||||
override fun execute(): Response<ApiResponse<T>> = throw NotImplementedError()
|
||||
override fun clone(): Call<ApiResponse<T>> = 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<ApiResponse<T>>,
|
||||
) : Callback<T> {
|
||||
|
||||
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||
val safeResponse = response.toSafeApiResponse()
|
||||
|
||||
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||
val e = if (t.isNetworkException()) {
|
||||
ApiResponseError.NetworkException
|
||||
} else {
|
||||
ApiResponseError.UnknownException(t)
|
||||
}
|
||||
val safeResponse = apiError<T>(e)
|
||||
|
||||
responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.datasource.api.common.response
|
||||
|
||||
fun <T : Any> ApiResponse<T>.getOrThrow(): T = when (this) {
|
||||
is ApiResponse.Error -> throw cause
|
||||
is ApiResponse.Success -> data
|
||||
}
|
||||
|
|
@ -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 <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<CurrenciesResponse>
|
||||
|
||||
@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<UserTokensResponse>
|
||||
|
||||
@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<QuotesResponse>
|
||||
}
|
||||
|
|
@ -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<RequestHeader>(CacheControlHeader).apply { header?.let(::add) }
|
||||
return Retrofit.Builder()
|
||||
.addConverterFactory(MoshiConverter.networkMoshiConverter)
|
||||
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
|
||||
.baseUrl(TANGEM_TECH_BASE_URL)
|
||||
.client(
|
||||
OkHttpClient.Builder()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@ interface CacheKeysStore {
|
|||
|
||||
suspend fun remove(key: String)
|
||||
|
||||
suspend fun remove(keys: Collection<String>)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -65,6 +65,13 @@ internal class FileDataStore<Value : Any>(
|
|||
writeTrigger.trigger()
|
||||
}
|
||||
|
||||
override suspend fun remove(keys: Collection<String>) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ internal class RuntimeDataStore<Data : Any> : StringKeyDataStore<Data> {
|
|||
}
|
||||
|
||||
override fun get(key: String): Flow<Data> {
|
||||
return store
|
||||
.map { value ->
|
||||
value?.get(key)
|
||||
}
|
||||
.filterNotNull()
|
||||
return store.mapNotNull { value ->
|
||||
value?.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAll(): Flow<List<Data>> {
|
||||
|
|
@ -42,20 +40,24 @@ internal class RuntimeDataStore<Data : Any> : StringKeyDataStore<Data> {
|
|||
}
|
||||
|
||||
override suspend fun store(values: Map<String, Data>) {
|
||||
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<String>) {
|
||||
updateValue { value ->
|
||||
HashMap(value.filterKeys { it !in keys })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ internal abstract class SharedPreferencesDataStore<Value : Any>(
|
|||
writeTrigger.trigger()
|
||||
}
|
||||
|
||||
override suspend fun remove(keys: Collection<String>) {
|
||||
sharedPreferences.edit {
|
||||
keys.forEach { key ->
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
writeTrigger.trigger()
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
sharedPreferences.edit { clear() }
|
||||
writeTrigger.trigger()
|
||||
|
|
|
|||
|
|
@ -18,5 +18,7 @@ internal interface DataStore<Key : Any, Value : Any> {
|
|||
|
||||
suspend fun remove(key: Key)
|
||||
|
||||
suspend fun remove(keys: Collection<Key>)
|
||||
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
@ -38,6 +38,10 @@ internal abstract class StringKeyDataStoreDecorator<Key : Any, Value : Any>(
|
|||
wrappedDataStore.remove(provideStringKey(key))
|
||||
}
|
||||
|
||||
override suspend fun remove(keys: Collection<Key>) {
|
||||
wrappedDataStore.remove(keys.map(::provideStringKey))
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
wrappedDataStore.clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<ApiResponseError>,
|
||||
) : Raise<ApiResponseError> 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 <T : Any> ApiResponse<T>.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 <T> 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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<String>)
|
||||
|
||||
/**
|
||||
* Invalidates all cache keys in the registry.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ internal class DefaultCacheRegistry(
|
|||
cacheKeysStore.remove(key)
|
||||
}
|
||||
|
||||
override suspend fun invalidate(keys: Collection<String>) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>, 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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue