Updated on 2026-08-14
This commit is contained in:
parent
24d7953389
commit
8683516c59
24 changed files with 817 additions and 148 deletions
|
|
@ -0,0 +1,113 @@
|
|||
package com.tangem.data.virtualaccount.converter
|
||||
|
||||
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.VirtualAccountStatusValue
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayCurrencyFactory
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Two-way converter between [VirtualAccountStatusValue] and [VirtualAccountStatusValueDM].
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
@Singleton
|
||||
internal class VirtualAccountStatusValueDMConverter @Inject constructor(
|
||||
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
|
||||
) {
|
||||
|
||||
fun convert(value: VirtualAccountStatusValue): VirtualAccountStatusValueDM? {
|
||||
return when (value) {
|
||||
is VirtualAccountStatusValue.Empty -> VirtualAccountStatusValueDM.Empty()
|
||||
is VirtualAccountStatusValue.NotCreated -> VirtualAccountStatusValueDM.NotCreated()
|
||||
is VirtualAccountStatusValue.UnderReview -> VirtualAccountStatusValueDM.UnderReview(
|
||||
kycStatus = value.kycStatus,
|
||||
customerId = value.customerId,
|
||||
)
|
||||
is VirtualAccountStatusValue.Provisioning -> VirtualAccountStatusValueDM.Provisioning()
|
||||
is VirtualAccountStatusValue.CountryNotSupported -> VirtualAccountStatusValueDM.CountryNotSupported()
|
||||
is VirtualAccountStatusValue.Active -> VirtualAccountStatusValueDM.ActiveAccount(
|
||||
customerId = value.customerId,
|
||||
currencyCode = value.currencyCode,
|
||||
depositAddress = value.depositAddress,
|
||||
fiatBalance = value.fiatBalance.toDM(),
|
||||
cryptoBalance = value.cryptoBalance.toDM(),
|
||||
fiatRate = value.fiatRate,
|
||||
availableForWithdrawal = value.availableForWithdrawal,
|
||||
)
|
||||
// Transient statuses are not persisted
|
||||
is VirtualAccountStatusValue.Loading,
|
||||
is VirtualAccountStatusValue.Error.ExposedDevice,
|
||||
is VirtualAccountStatusValue.Error.Unavailable,
|
||||
is VirtualAccountStatusValue.Error.NotSynced,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
|
||||
fun convertBack(userWalletId: UserWalletId, value: VirtualAccountStatusValueDM?): VirtualAccountStatusValue {
|
||||
return when (value) {
|
||||
is VirtualAccountStatusValueDM.Empty -> VirtualAccountStatusValue.Empty
|
||||
is VirtualAccountStatusValueDM.NotCreated -> VirtualAccountStatusValue.NotCreated
|
||||
is VirtualAccountStatusValueDM.Provisioning -> VirtualAccountStatusValue.Provisioning(
|
||||
source = StatusSource.CACHE,
|
||||
)
|
||||
is VirtualAccountStatusValueDM.CountryNotSupported -> VirtualAccountStatusValue.CountryNotSupported
|
||||
is VirtualAccountStatusValueDM.UnderReview -> VirtualAccountStatusValue.UnderReview(
|
||||
source = StatusSource.CACHE,
|
||||
kycStatus = value.kycStatus,
|
||||
customerId = value.customerId,
|
||||
)
|
||||
is VirtualAccountStatusValueDM.ActiveAccount -> VirtualAccountStatusValue.Active(
|
||||
source = StatusSource.CACHE,
|
||||
customerId = value.customerId,
|
||||
currencyCode = value.currencyCode,
|
||||
depositAddress = value.depositAddress,
|
||||
fiatBalance = value.fiatBalance.toDomain(),
|
||||
cryptoBalance = value.cryptoBalance.toDomain(),
|
||||
availableForWithdrawal = value.availableForWithdrawal,
|
||||
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
|
||||
fiatRate = value.fiatRate,
|
||||
)
|
||||
null -> VirtualAccountStatusValue.Error.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private fun VirtualAccountStatusValue.FiatBalance.toDM(): VirtualAccountStatusValueDM.FiatBalanceDM {
|
||||
return VirtualAccountStatusValueDM.FiatBalanceDM(
|
||||
availableBalance = availableBalance,
|
||||
currency = currency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun VirtualAccountStatusValue.CryptoBalance.toDM(): VirtualAccountStatusValueDM.CryptoBalanceDM {
|
||||
return VirtualAccountStatusValueDM.CryptoBalanceDM(
|
||||
id = id,
|
||||
chainId = chainId,
|
||||
depositAddress = depositAddress,
|
||||
tokenContractAddress = tokenContractAddress,
|
||||
balance = balance,
|
||||
)
|
||||
}
|
||||
|
||||
private fun VirtualAccountStatusValueDM.FiatBalanceDM.toDomain(): VirtualAccountStatusValue.FiatBalance {
|
||||
return VirtualAccountStatusValue.FiatBalance(
|
||||
availableBalance = availableBalance,
|
||||
currency = currency,
|
||||
)
|
||||
}
|
||||
|
||||
private fun VirtualAccountStatusValueDM.CryptoBalanceDM.toDomain(): VirtualAccountStatusValue.CryptoBalance {
|
||||
return VirtualAccountStatusValue.CryptoBalance(
|
||||
id = id,
|
||||
chainId = chainId,
|
||||
depositAddress = depositAddress,
|
||||
tokenContractAddress = tokenContractAddress,
|
||||
balance = balance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.tangem.data.virtualaccount.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
|
||||
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher
|
||||
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer
|
||||
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
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 javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface VirtualAccountDataModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindVirtualAccountStatusProducerFactory(
|
||||
impl: DefaultVirtualAccountStatusProducer.Factory,
|
||||
): VirtualAccountStatusProducer.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVirtualAccountStatusesStore(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
scope: AppCoroutineScope,
|
||||
converter: VirtualAccountStatusValueDMConverter,
|
||||
): VirtualAccountStatusesStore {
|
||||
return VirtualAccountStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceDataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = mapWithStringKeyTypes<VirtualAccountStatusValueDM>(),
|
||||
defaultValue = emptyMap(),
|
||||
),
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() },
|
||||
produceFile = { context.dataStoreFile(fileName = "virtual_account_statuses") },
|
||||
scope = scope,
|
||||
),
|
||||
converter = converter,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVirtualAccountStatusSupplier(
|
||||
factory: VirtualAccountStatusProducer.Factory,
|
||||
): VirtualAccountStatusSupplier {
|
||||
return object : VirtualAccountStatusSupplier(
|
||||
factory = factory,
|
||||
keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" },
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.data.virtualaccount.flow
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
|
||||
import com.tangem.domain.core.utils.catchOn
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.VirtualAccountStatusValue
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultVirtualAccountStatusFetcher @Inject constructor(
|
||||
private val virtualAccountStatusesStore: VirtualAccountStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : VirtualAccountStatusFetcher {
|
||||
|
||||
override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
|
||||
val account = Account.Virtual(userWalletId = params.userWalletId)
|
||||
// TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking
|
||||
// details) from the backend once Virtual Account status endpoints are available. Until then the
|
||||
// account is surfaced as NotCreated so the entity flows through the app end-to-end.
|
||||
virtualAccountStatusesStore.store(
|
||||
userWalletId = params.userWalletId,
|
||||
status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated),
|
||||
)
|
||||
}.onLeft {
|
||||
virtualAccountStatusesStore.updateStatusSource(
|
||||
userWalletId = params.userWalletId,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.tangem.data.virtualaccount.flow
|
||||
|
||||
import arrow.core.Option
|
||||
import arrow.core.some
|
||||
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
|
||||
import com.tangem.domain.core.flow.FlowProducerTools
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.VirtualAccountStatusValue
|
||||
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class DefaultVirtualAccountStatusProducer @AssistedInject constructor(
|
||||
@Assisted private val params: VirtualAccountStatusProducer.Params,
|
||||
override val flowProducerTools: FlowProducerTools,
|
||||
private val virtualAccountStatusesStore: VirtualAccountStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : VirtualAccountStatusProducer {
|
||||
|
||||
private val logger = TangemLogger.withTag(TAG)
|
||||
private val account = Account.Virtual(userWalletId = params.userWalletId)
|
||||
|
||||
override val fallback: Option<AccountStatus.Virtual>
|
||||
get() = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.Error.Unavailable).some()
|
||||
|
||||
override fun produce(): Flow<AccountStatus.Virtual> {
|
||||
return virtualAccountStatusesStore.get(userWalletId = params.userWalletId)
|
||||
.map { status ->
|
||||
if (status != null) {
|
||||
logger.i("[${params.userWalletId}] flow emits statusType=${status.value::class.simpleName}")
|
||||
AccountStatus.Virtual(
|
||||
account = account,
|
||||
value = status.value,
|
||||
)
|
||||
} else {
|
||||
logger.i("[${params.userWalletId}] status is null: emitting Empty fallback")
|
||||
AccountStatus.Virtual(
|
||||
account = account,
|
||||
value = VirtualAccountStatusValue.Empty,
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : VirtualAccountStatusProducer.Factory {
|
||||
override fun create(params: VirtualAccountStatusProducer.Params): DefaultVirtualAccountStatusProducer
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "VirtualAccountStatusProducer"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.data.virtualaccount.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.VirtualAccountStatusValue
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal typealias WalletIdWithVirtualStatus = Map<String, AccountStatus.Virtual>
|
||||
internal typealias WalletIdWithVirtualStatusDM = Map<String, VirtualAccountStatusValueDM>
|
||||
|
||||
/**
|
||||
* Store for virtual 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 VirtualAccountStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithVirtualStatus>,
|
||||
private val persistenceDataStore: DataStore<WalletIdWithVirtualStatusDM>,
|
||||
private val converter: VirtualAccountStatusValueDMConverter,
|
||||
scope: AppCoroutineScope,
|
||||
) {
|
||||
|
||||
private val logger = TangemLogger.withTag(TAG)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
|
||||
runtimeStore.store(
|
||||
value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) ->
|
||||
val account = Account.Virtual(userWalletId = UserWalletId(rawUserWalletId))
|
||||
val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM)
|
||||
AccountStatus.Virtual(account = account, value = statusValue)
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
runSuspendCatching { persistenceDataStore.updateData { emptyMap() } }
|
||||
logger.e("Error while loading cached virtual account statuses", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(userWalletId: UserWalletId): Flow<AccountStatus.Virtual?> {
|
||||
return runtimeStore.get()
|
||||
.onStart { logger.i("get($userWalletId): subscribed to runtimeStore") }
|
||||
.onEach { map ->
|
||||
logger.i(
|
||||
"get($userWalletId): runtimeStore emitted map size=${map.size}, " +
|
||||
"hasEntry=${map.containsKey(userWalletId.stringValue)}",
|
||||
)
|
||||
}
|
||||
.map { it[userWalletId.stringValue] }
|
||||
}
|
||||
|
||||
suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Virtual? {
|
||||
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
|
||||
}
|
||||
|
||||
suspend fun updateStatusSource(userWalletId: UserWalletId, source: StatusSource) {
|
||||
runtimeStore.update(emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
val status = this[userWalletId.stringValue] ?: return@update stored
|
||||
val newValue = status.copy(value = status.value.copySealed(source = source))
|
||||
put(key = userWalletId.stringValue, value = newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Virtual) {
|
||||
coroutineScope {
|
||||
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
|
||||
launch { storeInPersistence(userWalletId = userWalletId, status = status.value) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun contains(userWalletId: UserWalletId): Boolean {
|
||||
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Virtual) {
|
||||
runtimeStore.update(default = emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
put(key = userWalletId.stringValue, value = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: VirtualAccountStatusValue) {
|
||||
val statusDM = converter.convert(value = status) ?: return
|
||||
persistenceDataStore.updateData { storedStatuses ->
|
||||
storedStatuses.toMutableMap().apply {
|
||||
put(key = userWalletId.stringValue, value = statusDM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "VirtualAccountStatusesStore"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue