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.blockchain.common.derivation.DerivationStyle
|
||||||
import com.tangem.common.core.TangemSdkError
|
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.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechService
|
import com.tangem.datasource.api.tangemTech.TangemTechService
|
||||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||||
|
|
@ -106,7 +107,8 @@ class UserTokensRepository(
|
||||||
return runCatching { tangemTechApi.getUserTokens(userWalletId) }
|
return runCatching { tangemTechApi.getUserTokens(userWalletId) }
|
||||||
.fold(
|
.fold(
|
||||||
onSuccess = { response ->
|
onSuccess = { response ->
|
||||||
response.tokens
|
response.getOrThrow()
|
||||||
|
.tokens
|
||||||
.mapNotNull(Currency.Companion::fromTokenResponse)
|
.mapNotNull(Currency.Companion::fromTokenResponse)
|
||||||
.also { storageService.saveUserTokens(userWalletId, it.toUserTokensResponse()) }
|
.also { storageService.saveUserTokens(userWalletId, it.toUserTokensResponse()) }
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.tangem.tap.features.wallet.data
|
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.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||||
import com.tangem.tap.features.wallet.domain.WalletRepository
|
import com.tangem.tap.features.wallet.domain.WalletRepository
|
||||||
|
|
@ -18,6 +19,6 @@ class WalletRepositoryImpl(
|
||||||
) : WalletRepository {
|
) : WalletRepository {
|
||||||
|
|
||||||
override suspend fun getCurrencyList(): CurrenciesResponse = withContext(dispatchers.io) {
|
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
|
package com.tangem.datasource.api.tangemTech
|
||||||
|
|
||||||
|
import com.tangem.datasource.api.common.response.ApiResponse
|
||||||
import com.tangem.datasource.api.tangemTech.models.*
|
import com.tangem.datasource.api.tangemTech.models.*
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
|
@ -25,13 +26,13 @@ interface TangemTechApi {
|
||||||
suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse
|
suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse
|
||||||
|
|
||||||
@GET("currencies")
|
@GET("currencies")
|
||||||
suspend fun getCurrencyList(): CurrenciesResponse
|
suspend fun getCurrencyList(): ApiResponse<CurrenciesResponse>
|
||||||
|
|
||||||
@GET("geo")
|
@GET("geo")
|
||||||
suspend fun getUserCountryCode(): GeoResponse
|
suspend fun getUserCountryCode(): GeoResponse
|
||||||
|
|
||||||
@GET("user-tokens/{user-id}")
|
@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}")
|
@PUT("user-tokens/{user-id}")
|
||||||
suspend fun saveUserTokens(@Path(value = "user-id") userId: String, @Body userTokens: UserTokensResponse)
|
suspend fun saveUserTokens(@Path(value = "user-id") userId: String, @Body userTokens: UserTokensResponse)
|
||||||
|
|
@ -66,5 +67,5 @@ interface TangemTechApi {
|
||||||
@Query("currencyId") currencyId: String,
|
@Query("currencyId") currencyId: String,
|
||||||
@Query("coinIds") coinIds: String,
|
@Query("coinIds") coinIds: String,
|
||||||
@Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt",
|
@Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt",
|
||||||
): QuotesResponse
|
): ApiResponse<QuotesResponse>
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.datasource.api.tangemTech
|
package com.tangem.datasource.api.tangemTech
|
||||||
|
|
||||||
import com.tangem.datasource.api.common.MoshiConverter
|
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
|
||||||
import com.tangem.datasource.utils.RequestHeader.AuthenticationHeader
|
import com.tangem.datasource.utils.RequestHeader.AuthenticationHeader
|
||||||
import com.tangem.datasource.utils.RequestHeader.CacheControlHeader
|
import com.tangem.datasource.utils.RequestHeader.CacheControlHeader
|
||||||
|
|
@ -29,6 +30,7 @@ object TangemTechService {
|
||||||
val headers = mutableListOf<RequestHeader>(CacheControlHeader).apply { header?.let(::add) }
|
val headers = mutableListOf<RequestHeader>(CacheControlHeader).apply { header?.let(::add) }
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.addConverterFactory(MoshiConverter.networkMoshiConverter)
|
.addConverterFactory(MoshiConverter.networkMoshiConverter)
|
||||||
|
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
|
||||||
.baseUrl(TANGEM_TECH_BASE_URL)
|
.baseUrl(TANGEM_TECH_BASE_URL)
|
||||||
.client(
|
.client(
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.datasource.di
|
package com.tangem.datasource.di
|
||||||
|
|
||||||
import com.squareup.moshi.Moshi
|
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.paymentology.PaymentologyApi
|
||||||
import com.tangem.datasource.api.promotion.PromotionApi
|
import com.tangem.datasource.api.promotion.PromotionApi
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
|
|
@ -27,6 +28,7 @@ class NetworkModule {
|
||||||
fun provideTangemTechApi(@NetworkMoshi moshi: Moshi): TangemTechApi {
|
fun provideTangemTechApi(@NetworkMoshi moshi: Moshi): TangemTechApi {
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
|
.addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
|
||||||
.baseUrl(PROD_TANGEM_TECH_BASE_URL)
|
.baseUrl(PROD_TANGEM_TECH_BASE_URL)
|
||||||
.client(
|
.client(
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,7 @@ interface CacheKeysStore {
|
||||||
|
|
||||||
suspend fun remove(key: String)
|
suspend fun remove(key: String)
|
||||||
|
|
||||||
|
suspend fun remove(keys: Collection<String>)
|
||||||
|
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +65,13 @@ internal class FileDataStore<Value : Any>(
|
||||||
writeTrigger.trigger()
|
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() {
|
override suspend fun clear() {
|
||||||
val e = NotImplementedError("`clear()` function not implemented for `FileDataStore`")
|
val e = NotImplementedError("`clear()` function not implemented for `FileDataStore`")
|
||||||
Timber.e(e)
|
Timber.e(e)
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ internal class RuntimeDataStore<Data : Any> : StringKeyDataStore<Data> {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun get(key: String): Flow<Data> {
|
override fun get(key: String): Flow<Data> {
|
||||||
return store
|
return store.mapNotNull { value ->
|
||||||
.map { value ->
|
value?.get(key)
|
||||||
value?.get(key)
|
}
|
||||||
}
|
|
||||||
.filterNotNull()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAll(): Flow<List<Data>> {
|
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>) {
|
override suspend fun store(values: Map<String, Data>) {
|
||||||
updateValue { value ->
|
updateValue { storedValue ->
|
||||||
values.forEach { (key, item) ->
|
storedValue.putAll(values)
|
||||||
value[key] = item
|
|
||||||
}
|
|
||||||
|
|
||||||
value
|
storedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun remove(key: String) {
|
override suspend fun remove(key: String) {
|
||||||
updateValue { value ->
|
updateValue { storedValue ->
|
||||||
value.remove(key)
|
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()
|
writeTrigger.trigger()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(keys: Collection<String>) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
keys.forEach { key ->
|
||||||
|
remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTrigger.trigger()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun clear() {
|
override suspend fun clear() {
|
||||||
sharedPreferences.edit { clear() }
|
sharedPreferences.edit { clear() }
|
||||||
writeTrigger.trigger()
|
writeTrigger.trigger()
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,7 @@ internal interface DataStore<Key : Any, Value : Any> {
|
||||||
|
|
||||||
suspend fun remove(key: Key)
|
suspend fun remove(key: Key)
|
||||||
|
|
||||||
|
suspend fun remove(keys: Collection<Key>)
|
||||||
|
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +38,10 @@ internal abstract class StringKeyDataStoreDecorator<Key : Any, Value : Any>(
|
||||||
wrappedDataStore.remove(provideStringKey(key))
|
wrappedDataStore.remove(provideStringKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun remove(keys: Collection<Key>) {
|
||||||
|
wrappedDataStore.remove(keys.map(::provideStringKey))
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun clear() {
|
override suspend fun clear() {
|
||||||
wrappedDataStore.clear()
|
wrappedDataStore.clear()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.tangem.data.appcurrency
|
package com.tangem.data.appcurrency
|
||||||
|
|
||||||
import com.tangem.data.appcurrency.utils.AppCurrencyConverter
|
import com.tangem.data.appcurrency.utils.AppCurrencyConverter
|
||||||
|
import com.tangem.data.common.api.safeApiCall
|
||||||
import com.tangem.data.common.cache.CacheRegistry
|
import com.tangem.data.common.cache.CacheRegistry
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||||
|
|
@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.joda.time.Duration
|
import org.joda.time.Duration
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
internal class DefaultAppCurrencyRepository(
|
internal class DefaultAppCurrencyRepository(
|
||||||
private val tangemTechApi: TangemTechApi,
|
private val tangemTechApi: TangemTechApi,
|
||||||
|
|
@ -80,17 +80,15 @@ internal class DefaultAppCurrencyRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchAvailableCurrencies() {
|
private suspend fun fetchAvailableCurrencies() {
|
||||||
try {
|
val response = safeApiCall(
|
||||||
val response = tangemTechApi.getCurrencyList()
|
call = { tangemTechApi.getCurrencyList().bind() },
|
||||||
|
onError = {
|
||||||
|
cacheRegistry.invalidate(AVAILABLE_CURRENCIES_CACHE_KEY)
|
||||||
|
getDefaultCurrenciesResponse()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
availableAppCurrenciesStore.store(response)
|
availableAppCurrenciesStore.store(response)
|
||||||
} catch (e: Throwable) {
|
|
||||||
Timber.e(e, "Unable to fetch available currencies")
|
|
||||||
|
|
||||||
availableAppCurrenciesStore.store(getDefaultCurrenciesResponse())
|
|
||||||
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDefaultCurrenciesResponse(): CurrenciesResponse = CurrenciesResponse(
|
private fun getDefaultCurrenciesResponse(): CurrenciesResponse = CurrenciesResponse(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies {
|
||||||
implementation(deps.kotlin.coroutines)
|
implementation(deps.kotlin.coroutines)
|
||||||
implementation(deps.jodatime)
|
implementation(deps.jodatime)
|
||||||
implementation(deps.timber)
|
implementation(deps.timber)
|
||||||
|
implementation(deps.arrow.core)
|
||||||
|
|
||||||
implementation(deps.hilt.android)
|
implementation(deps.hilt.android)
|
||||||
kapt(deps.hilt.kapt)
|
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)
|
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.
|
* Invalidates all cache keys in the registry.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ internal class DefaultCacheRegistry(
|
||||||
cacheKeysStore.remove(key)
|
cacheKeysStore.remove(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun invalidate(keys: Collection<String>) {
|
||||||
|
Timber.d("Invalidate cache keys: $keys")
|
||||||
|
cacheKeysStore.remove(keys)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun invalidateAll() {
|
override suspend fun invalidateAll() {
|
||||||
Timber.d("Invalidate all cache keys")
|
Timber.d("Invalidate all cache keys")
|
||||||
cacheKeysStore.clear()
|
cacheKeysStore.clear()
|
||||||
|
|
@ -32,14 +37,14 @@ internal class DefaultCacheRegistry(
|
||||||
key: String,
|
key: String,
|
||||||
skipCache: Boolean,
|
skipCache: Boolean,
|
||||||
expireIn: Duration,
|
expireIn: Duration,
|
||||||
action: suspend () -> Unit,
|
block: suspend () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isExpired = isExpired(key) || skipCache
|
val isExpired = isExpired(key) || skipCache
|
||||||
if (!isExpired) return
|
if (!isExpired) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Timber.d("Invoke the action associated with the cache key: $key")
|
Timber.d("Invoke the action associated with the cache key: $key")
|
||||||
action()
|
block()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Timber.w(e, "The action related to the cache key has failed: $key")
|
Timber.w(e, "The action related to the cache key has failed: $key")
|
||||||
throw e
|
throw e
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package com.tangem.data.tokens.repository
|
package com.tangem.data.tokens.repository
|
||||||
|
|
||||||
import com.tangem.blockchain.common.Blockchain
|
import com.tangem.blockchain.common.Blockchain
|
||||||
|
import com.tangem.data.common.api.safeApiCall
|
||||||
import com.tangem.data.common.cache.CacheRegistry
|
import com.tangem.data.common.cache.CacheRegistry
|
||||||
import com.tangem.data.tokens.utils.*
|
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.TangemTechApi
|
||||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||||
import com.tangem.datasource.local.token.UserMarketCoinsStore
|
import com.tangem.datasource.local.token.UserMarketCoinsStore
|
||||||
|
|
@ -21,10 +23,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import retrofit2.HttpException
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.net.ConnectException
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
|
|
||||||
internal class DefaultCurrenciesRepository(
|
internal class DefaultCurrenciesRepository(
|
||||||
private val tangemTechApi: TangemTechApi,
|
private val tangemTechApi: TangemTechApi,
|
||||||
|
|
@ -259,14 +258,14 @@ internal class DefaultCurrenciesRepository(
|
||||||
private suspend fun fetchTokens(userWallet: UserWallet) {
|
private suspend fun fetchTokens(userWallet: UserWallet) {
|
||||||
val userWalletId = userWallet.walletId
|
val userWalletId = userWallet.walletId
|
||||||
|
|
||||||
val response = try {
|
val response = safeApiCall(
|
||||||
with(tangemTechApi.getUserTokens(userWalletId.stringValue)) {
|
call = {
|
||||||
// The response may contain repeated tokens
|
tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let {
|
||||||
copy(tokens = tokens.distinct())
|
it.copy(tokens = it.tokens.distinct())
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
},
|
||||||
handleFetchTokensError(userWallet, e)
|
onError = { handleFetchTokensError(userWallet, it) },
|
||||||
}
|
)
|
||||||
|
|
||||||
userTokensStore.store(userWallet.walletId, response)
|
userTokensStore.store(userWallet.walletId, response)
|
||||||
fetchUserMarketCoinsByIds(userWalletId, 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 userWalletId = userWallet.walletId
|
||||||
val response = userTokensStore.getSyncOrNull(userWalletId)
|
val response = userTokensStore.getSyncOrNull(userWalletId)
|
||||||
?: userTokensResponseFactory.createUserTokensResponse(
|
?: userTokensResponseFactory.createUserTokensResponse(
|
||||||
|
|
@ -297,27 +296,12 @@ internal class DefaultCurrenciesRepository(
|
||||||
isSortedByBalance = false,
|
isSortedByBalance = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (throwable) {
|
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
|
||||||
is ConnectException,
|
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
|
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
|
||||||
} else {
|
} else {
|
||||||
Timber.e(throwable, "Unable to fetch currencies for: $userWalletId")
|
cacheRegistry.invalidate(getTokensCacheKey(userWalletId))
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
throw throwable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.tangem.data.tokens.repository
|
package com.tangem.data.tokens.repository
|
||||||
|
|
||||||
|
import com.tangem.data.common.api.safeApiCall
|
||||||
import com.tangem.data.common.cache.CacheRegistry
|
import com.tangem.data.common.cache.CacheRegistry
|
||||||
import com.tangem.data.tokens.utils.QuotesConverter
|
import com.tangem.data.tokens.utils.QuotesConverter
|
||||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||||
|
|
@ -12,7 +13,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
internal class DefaultQuotesRepository(
|
internal class DefaultQuotesRepository(
|
||||||
private val tangemTechApi: TangemTechApi,
|
private val tangemTechApi: TangemTechApi,
|
||||||
|
|
@ -72,15 +72,20 @@ internal class DefaultQuotesRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchQuotes(rawCurrenciesIds: Set<String>, appCurrencyId: String) {
|
private suspend fun fetchQuotes(rawCurrenciesIds: Set<String>, appCurrencyId: String) {
|
||||||
val response = try {
|
val response = safeApiCall(
|
||||||
val coinIds = rawCurrenciesIds.joinToString(separator = ",")
|
call = {
|
||||||
tangemTechApi.getQuotes(appCurrencyId, coinIds)
|
val coinIds = rawCurrenciesIds.joinToString(separator = ",")
|
||||||
} catch (e: Throwable) {
|
tangemTechApi.getQuotes(appCurrencyId, coinIds).bind()
|
||||||
Timber.e(e, "Unable to fetch quotes for: $rawCurrenciesIds")
|
},
|
||||||
throw e
|
onError = {
|
||||||
}
|
cacheRegistry.invalidate(rawCurrenciesIds.map(::getQuoteCacheKey))
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
quotesStore.store(response)
|
if (response != null) {
|
||||||
|
quotesStore.store(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun filterExpiredCurrenciesIds(
|
private suspend fun filterExpiredCurrenciesIds(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue