Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-25 16:00:39 +05:00
parent a0ba5ab9d9
commit 286e28223c
7 changed files with 248 additions and 14 deletions

View file

@ -38,9 +38,6 @@ dependencies {
implementation(projects.domain.common)
implementation(projects.features.swap.domain)
/** Feature API - remove after removing [HotWalletFeatureToggles] */
implementation(projects.features.hotWallet.api)
/** Project - Utils */
implementation(projects.core.utils)
@ -51,6 +48,7 @@ dependencies {
implementation(projects.libs.visa)
/** Libs - Other */
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.arrow.fx)
@ -68,6 +66,6 @@ dependencies {
implementation(projects.libs.tangemSdkApi)
/** DI */
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,67 @@
package com.tangem.data.pay.converter
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convert
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convertBack
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between [PaymentAccountStatus] and [PaymentAccountStatusDM].
*
* [convert] maps domain data model. Returns null for transient statuses that should not be persisted
* (Loading, ExposedDevice, Unavailable, NotSynced).
*
* [convertBack] maps data model domain. All restored statuses have [StatusSource.CACHE] as source.
*/
internal object PaymentAccountStatusDMConverter :
TwoWayConverter<PaymentAccountStatus, PaymentAccountStatusDM?> {
override fun convert(value: PaymentAccountStatus): PaymentAccountStatusDM? {
return when (value) {
is PaymentAccountStatus.NotCreated -> PaymentAccountStatusDM.NotCreated()
is PaymentAccountStatus.UnderReview -> PaymentAccountStatusDM.UnderReview(kycStatus = value.kycStatus)
is PaymentAccountStatus.IssuingCard -> PaymentAccountStatusDM.IssuingCard()
is PaymentAccountStatus.Locked -> PaymentAccountStatusDM.Locked()
is PaymentAccountStatus.Loaded -> PaymentAccountStatusDM.Loaded(
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
is PaymentAccountStatus.Error.CardIssueFailed -> PaymentAccountStatusDM.CardIssueFailed()
// Transient statuses are not persisted
is PaymentAccountStatus.Loading,
is PaymentAccountStatus.Error.ExposedDevice,
is PaymentAccountStatus.Error.Unavailable,
is PaymentAccountStatus.Error.NotSynced,
-> null
}
}
override fun convertBack(value: PaymentAccountStatusDM?): PaymentAccountStatus {
return when (value) {
is PaymentAccountStatusDM.CardIssueFailed -> PaymentAccountStatus.Error.CardIssueFailed
is PaymentAccountStatusDM.NotCreated -> PaymentAccountStatus.NotCreated
is PaymentAccountStatusDM.IssuingCard -> PaymentAccountStatus.IssuingCard(source = StatusSource.CACHE)
is PaymentAccountStatusDM.Locked -> PaymentAccountStatus.Locked(source = StatusSource.CACHE)
is PaymentAccountStatusDM.UnderReview -> PaymentAccountStatus.UnderReview(
source = StatusSource.CACHE,
kycStatus = value.kycStatus,
)
is PaymentAccountStatusDM.Loaded -> PaymentAccountStatus.Loaded(
source = StatusSource.CACHE,
cardId = value.cardId,
lastFourDigits = value.lastFourDigits,
balance = value.balance,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
isPinSet = value.isPinSet,
)
null -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.CACHE)
}
}
}

View file

@ -1,13 +1,23 @@
package com.tangem.data.pay.di
import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -21,11 +31,15 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -92,6 +106,28 @@ internal interface TangemPayDataModule {
companion object {
@Provides
@Singleton
fun providePaymentAccountStatusesStore(
@NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): PaymentAccountStatusesStore {
return PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<PaymentAccountStatusDM>(),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun providePaymentAccountStatusSupplier(

View file

@ -1,24 +1,86 @@
package com.tangem.data.pay.store
import androidx.datastore.core.DataStore
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import timber.log.Timber
@Suppress("UnusedParameter", "EmptyFunctionBlock", "FunctionOnlyReturningConstant")
@Singleton
internal class PaymentAccountStatusesStore @Inject constructor() {
internal typealias WalletIdWithPaymentStatus = Map<String, PaymentAccountStatus>
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusDM>
/**
* Store for payment account statuses with dual storage (runtime + persistence).
*
* @property runtimeStore runtime store for fast in-memory access
* @property persistenceDataStore persistence store for caching across app restarts
*/
internal class PaymentAccountStatusesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
dispatchers: CoroutineDispatcherProvider,
) {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
try {
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
value = cachedStatuses.mapValues { (_, statusDM) ->
PaymentAccountStatusDMConverter.convertBack(statusDM)
},
)
} catch (e: Exception) {
Timber.e(e, "Error while loading cached payment account statuses")
}
}
}
fun get(userWalletId: UserWalletId): Flow<PaymentAccountStatus> {
return emptyFlow()
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
}
fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
return null
suspend fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
}
fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
suspend fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
coroutineScope {
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
launch { storeInPersistence(userWalletId = userWalletId, status = status) }
}
}
suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: PaymentAccountStatus) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {
put(key = userWalletId.stringValue, value = status)
}
}
}
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatus) {
val statusDM = PaymentAccountStatusDMConverter.convert(value = status) ?: return
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
put(key = userWalletId.stringValue, value = statusDM)
}
}
}
}