From 9af7bad7b859e9246a6ff9278390072eeb329595 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 7 Jul 2026 17:09:10 +0400 Subject: [PATCH] Updated on 2026-08-14 --- data/txhistory/build.gradle.kts | 4 + .../data/txhistory/di/TxHistoryDataModule.kt | 5 + .../fetcher/TxHistoryFetcherUtils.kt | 7 +- .../list/DefaultHistoryTxListManager.kt | 178 +++++++++ .../txhistory/list/TxHistoryInfoMerger.kt | 85 +++++ .../list/chain/BsdkOnChainHistory.kt | 138 +++++++ .../list/chain/IndexTableOnChainHistory.kt | 73 ++++ .../txhistory/list/chain/OnChainHistory.kt | 20 + .../list/chain/TangemPayOnChainHistory.kt | 118 ++++++ .../list/DefaultHistoryTxListManagerTest.kt | 354 ++++++++++++++++++ .../list}/TxHistoryInfoMergerTest.kt | 2 +- .../list/chain/BsdkOnChainHistoryTest.kt | 250 +++++++++++++ .../chain/IndexTableOnChainHistoryTest.kt | 142 +++++++ .../list/chain/TangemPayOnChainHistoryTest.kt | 177 +++++++++ domain/txhistory/build.gradle.kts | 1 + .../txhistory/list/HistoryTxListManager.kt | 74 ++++ .../domain/txhistory/model/TxHistoryInfo.kt | 14 +- ...HistoryInfoToTransactionItemUMConverter.kt | 2 + ...istoryInfoToTxHistoryDetailsUMConverter.kt | 2 + .../txhistory/model/TxHistoryModel.kt | 68 ++-- .../txhistory/utils/HistoryTxListManager.kt | 153 -------- .../txhistory/utils/TxHistoryInfoMerger.kt | 51 --- 22 files changed, 1680 insertions(+), 238 deletions(-) create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManager.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/OnChainHistory.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistory.kt create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManagerTest.kt rename {features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils => data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list}/TxHistoryInfoMergerTest.kt (99%) create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt create mode 100644 data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt create mode 100644 domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/list/HistoryTxListManager.kt delete mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt delete mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt diff --git a/data/txhistory/build.gradle.kts b/data/txhistory/build.gradle.kts index 7e75acd400..0c27a50143 100644 --- a/data/txhistory/build.gradle.kts +++ b/data/txhistory/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { api(projects.domain.walletManager) api(projects.domain.wallets) implementation(projects.domain.account.status) + implementation(projects.domain.legacy) + implementation(projects.domain.visa) // endregion // region Domain models @@ -60,6 +62,8 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.tokens.models) implementation(projects.domain.txhistory.models) + implementation(projects.domain.visa.models) + implementation(projects.domain.wallets.models) // endregion // region Libs diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt index 7afc027d50..686e6dcaa0 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/di/TxHistoryDataModule.kt @@ -4,10 +4,12 @@ import com.tangem.data.common.txhistory.ExpressHistoryRepository import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils +import com.tangem.data.txhistory.list.DefaultHistoryTxListManager import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher +import com.tangem.domain.txhistory.list.HistoryTxListManager import com.tangem.domain.txhistory.repository.TxHistoryRepository import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import dagger.Binds @@ -38,4 +40,7 @@ internal interface TxHistoryDataModule { @Binds @Singleton fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository + + @Binds + fun provideHistoryTxListManagerFactory(default: DefaultHistoryTxListManager.Factory): HistoryTxListManager.Factory } \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/TxHistoryFetcherUtils.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/TxHistoryFetcherUtils.kt index ee66c94271..f434a85e5a 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/TxHistoryFetcherUtils.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/TxHistoryFetcherUtils.kt @@ -2,13 +2,13 @@ package com.tangem.data.txhistory.fetcher import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import kotlinx.coroutines.plus import javax.inject.Inject const val TX_HISTORY_TAG = "TxHistory" @@ -30,6 +30,11 @@ internal interface TxHistoryFetcherUtils { fun TxHistoryFetcherUtils.defaultLaunchIn(flow: Flow) = flow .retry { error -> logError(error) + val event = ExceptionAnalyticsEvent( + exception = error, + params = mapOf("source" to TX_HISTORY_TAG), + ) + analyticsExceptionHandler.sendException(event) true } .launchIn(fetcherScope) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManager.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManager.kt new file mode 100644 index 0000000000..668659248d --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManager.kt @@ -0,0 +1,178 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.tangem.data.txhistory.list + +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.data.txhistory.fetcher.TX_HISTORY_TAG +import com.tangem.data.txhistory.list.chain.* +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.txhistory.list.HistoryTxListManager +import com.tangem.domain.txhistory.list.HistoryTxListManager.* +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class DefaultHistoryTxListManager @AssistedInject constructor( + dispatchers: CoroutineDispatcherProvider, + private val expressServiceFetcher: ExpressServiceFetcher, + private val paymentAccountCurrency: GetPaymentAccountCryptoCurrencyStatusUseCase, + private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, + private val bsdkOnChainHistoryFactory: BsdkOnChainHistory.Factory, + private val tangemPayOnChainHistoryFactory: TangemPayOnChainHistory.Factory, + private val indexTableOnChainHistoryFactory: IndexTableOnChainHistory.Factory, + @Assisted private val userWalletId: UserWalletId, + @Assisted private val currency: CryptoCurrency, + @Assisted private val modelScope: CoroutineScope, +) : HistoryTxListManager { + + private val actionsFlow: Channel = Channel() + private val _historySources: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val state: StateFlow + override val historySources: Flow get() = _historySources.distinctUntilChanged() + + init { + state = buildPipeline() + .retry { error -> + logError(error) + true + } + .flowOn(dispatchers.io) + .stateIn(modelScope, SharingStarted.Eagerly, HistoryState.Loading) + } + + override fun reload() { + actionsFlow.trySend(Action.Reload(shouldRefresh = true)) + } + + override fun loadMore() { + actionsFlow.trySend(Action.LoadMore) + } + + private fun buildPipeline() = channelFlow { + val reloadInitialLoading = flow { + // initial load + emit(Unit) + // any action triggers a reload + emitAll(actionsFlow.receiveAsFlow().map { }) + } + val historySources = reloadInitialLoading + .onEach { channel.send(HistoryState.Loading) } + .mapNotNull { + val result = runSuspendCatching { loadSources() }.getOrNull() + // Error state, wait for any action to reload + if (result == null) channel.send(HistoryState.Error) + result + } + .onEach { _historySources.tryEmit(it) } + .first() + if (!isHistoryAvailable(historySources)) { + channel.send(HistoryState.Unavailable) + return@channelFlow + } + + val env = HistoryEnvironment( + userWalletId = userWalletId, + currency = currency, + modelScope = modelScope, + ) + val onChainHistory: OnChainHistory = when (historySources.onChainSource) { + OnChainSource.BSDK -> bsdkOnChainHistoryFactory.create(env) + OnChainSource.TangemPay -> tangemPayOnChainHistoryFactory.create(env) + OnChainSource.IndexTable -> indexTableOnChainHistoryFactory.create(env) + } + + actionsFlow.receiveAsFlow() + .onEach { onChainHistory.sendAction(it) } + .launchIn(this) + + onChainHistory.history().collect { channel.send(it) } + } + + private suspend fun loadSources(): HistorySources = coroutineScope { + val onChainSource = async { resolveOnChainSource() } + val expressAsset = async { awaitExpressAsset() } + + val asset = expressAsset.await() + HistorySources( + onChainSource = onChainSource.await(), + isExchangeAvailable = asset?.isExchangeAvailable == true, + isOnrampAvailable = asset?.isOnrampAvailable == true, + ) + } + + private fun isHistoryAvailable(sources: HistorySources): Boolean = with(sources) { + when (onChainSource) { + OnChainSource.BSDK -> true + OnChainSource.TangemPay -> true + OnChainSource.IndexTable -> isExchangeAvailable || isOnrampAvailable + } + } + + private suspend fun resolveOnChainSource(): OnChainSource { + val isCryptoPortfolio = getAccountCryptoCurrencyStatusUseCase.invokeSync(userWalletId, currency).isSome() + return when { + isCryptoPortfolio -> txHistoryItemsCountUseCase(userWalletId, currency).fold( + ifLeft = { error -> + when (error) { + is TxHistoryStateError.DataError -> throw error + TxHistoryStateError.EmptyTxHistories -> OnChainSource.BSDK + TxHistoryStateError.TxHistoryNotImplemented -> OnChainSource.IndexTable + } + }, + ifRight = { OnChainSource.BSDK }, + ) + + paymentAccountCurrency.invokeSync(userWalletId, currency).isSome() -> OnChainSource.TangemPay + else -> OnChainSource.IndexTable + } + } + + private suspend fun awaitExpressAsset(): ExpressAsset? { + val assetId = ExpressAsset.ID(currency) + return expressServiceFetcher.getOrFetch(userWalletId, assetId).getOrNull() + } + + private fun logError(error: Throwable) { + val message = error.message.orEmpty() + TangemLogger.withTag(TX_HISTORY_TAG).e(message, error) + val event = ExceptionAnalyticsEvent( + exception = error, + params = mapOf("source" to TX_HISTORY_TAG), + ) + analyticsExceptionHandler.sendException(event) + } + + @AssistedFactory + interface Factory : HistoryTxListManager.Factory { + override fun create( + userWalletId: UserWalletId, + currency: CryptoCurrency, + modelScope: CoroutineScope, + ): DefaultHistoryTxListManager + } +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt new file mode 100644 index 0000000000..070e0156c3 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMerger.kt @@ -0,0 +1,85 @@ +package com.tangem.data.txhistory.list + +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.domain.txhistory.model.explorerHash + +/** + * Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single + * timestamp-DESC timeline. + * + * Per express op (matched to on-chain by [ExpressTx.matchHash]): + * - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es) + * of that hash are collapsed into this row (not emitted standalone). + * - unmatched → standalone row (status shown, no on-chain leg). Both in-progress and terminal + * (finished/failed) express ops are shown so the user always sees their deals. + * + * On-chain transactions that no express op claimed pass through as [OnChainTx]. + * [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller. + */ +internal fun mergeTxHistoryInfos(onChain: List, express: List): List { + val onChainByHash = onChain.associateBy { it.txHash } + val matchedHashes = mutableSetOf() + val result = mutableListOf() + + express.forEach { op -> + val matched = op.matchHash?.let(onChainByHash::get) + if (matched != null) { + result += op.withMatchedOnChain(OnChainTx.BSDK(matched)) + matchedHashes += matched.txHash + } else { + result += op + } + } + + onChain.forEach { tx -> + if (tx.txHash !in matchedHashes) { + result += OnChainTx.BSDK(tx) + } + } + + return result.sortedByDescending(TxHistoryInfo::timestampMillis) +} + +/** + * TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the + * express overlay. Same rules as [mergeTxHistoryInfos] — matched express ops enrich (carry their + * on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through. + * + * TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the + * express op's [ExpressTx.matchHash]. + * + * WARNING: this hash-based match has NOT been validated against real TangemPay data yet — TangemPay is + * not wired into the history end-to-end. Re-verify the hash semantics (which field carries the on-chain + * hash, and that it lines up with the express payin/payout hash) when TangemPay is integrated for real. + */ +internal fun mergeTangemPay(onChain: List, express: List): List { + val onChainByHash = onChain.mapNotNull { tx -> tx.explorerHash?.let { it to tx } }.toMap() + val matchedHashes = mutableSetOf() + val result = mutableListOf() + + express.forEach { op -> + val matched = op.matchHash?.let(onChainByHash::get) + if (matched != null) { + result += op.withMatchedOnChain(matched) + matched.explorerHash?.let(matchedHashes::add) + } else { + result += op + } + } + + onChain.forEach { tx -> + if (tx.explorerHash !in matchedHashes) { + result += tx + } + } + + return result.sortedByDescending(TxHistoryInfo::timestampMillis) +} + +private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) { + is ExpressTx.Swap -> copy(txInfo = onChain) + is ExpressTx.Onramp -> copy(txInfo = onChain) +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt new file mode 100644 index 0000000000..e40f99d1bd --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistory.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.tangem.data.txhistory.list.chain + +import com.tangem.data.txhistory.list.mergeTxHistoryInfos +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext +import com.tangem.domain.txhistory.model.TxHistoryListConfig +import com.tangem.domain.txhistory.model.identityKey +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +private typealias HistoryTxBatchAction = BatchAction + +/** On-chain backbone backed by the blockchain SDK history, with the express overlay windowed to the loaded page. */ +internal class BsdkOnChainHistory @AssistedInject constructor( + private val repository: TxHistoryRepositoryV2, + @Assisted private val env: HistoryEnvironment, +) : OnChainHistory { + private val userWalletId get() = env.userWalletId + private val currency get() = env.currency + + override val actions: Channel = Channel() + + override fun history(): Flow { + val actionsFlow = flow { + // initial load + emit(Action.Reload(shouldRefresh = false)) + // receive user actions + actions.receiveAsFlow().collect { emit(it) } + } + val batchFlow = repository.getTxHistoryBatchFlow( + context = TxHistoryListBatchingContext( + actionsFlow = actionsFlow.map { it.toBatchAction() }, + coroutineScope = env.modelScope, + ), + batchSize = BATCH_SIZE, + ) + + return batchFlow.state + .onEach { batchState -> autoLoadMoreUntilScrollable(batchState) } + .flatMapLatest { batchState -> buildState(batchState) } + } + + /** + * Keeps requesting the next page while the loaded on-chain backbone is too short to make the list scrollable + * (fewer than [AUTO_LOAD_MORE_TARGET_COUNT] items) or the last page came back empty. Runs only in the + * [PaginationStatus.Paginating] state, so it stops as soon as the backbone reaches [PaginationStatus.EndOfPagination]. + */ + private fun autoLoadMoreUntilScrollable(batchState: BatchListState>) { + val status = batchState.status as? PaginationStatus.Paginating ?: return + val lastResult = status.lastResult as? BatchFetchResult.Success ?: return + val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size } + val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty + if (shouldLoadMore) { + sendAction(Action.LoadMore) + } + } + + private fun buildState(batchState: BatchListState>): Flow { + val mergedFlow: Flow> = repository.getExpressHistory( + userWalletId = userWalletId, + currency = currency, + fromCreatedAtMillis = oldestLoadedTimestamp(batchState), + ).map { express -> + val onChain = batchState.data.asSequence() + .flatMap { it.data.items.asSequence() } + .distinctBy(TxInfo::identityKey) + .toList() + + mergeTxHistoryInfos(onChain = onChain, express = express) + } + + return when (batchState.status) { + PaginationStatus.None, + PaginationStatus.InitialLoading, + -> flowOf(HistoryState.Loading) + // Terminal initial failure: surface Error so the user can retry the on-chain page load. + is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error) + PaginationStatus.NextBatchLoading -> mergedFlow.map { merged -> + HistoryState.Content(merged, isLoadingMore = true, hasMore = true) + } + is PaginationStatus.Paginating<*> -> mergedFlow.map { merged -> + if (merged.isEmpty()) { + HistoryState.Empty + } else { + HistoryState.Content(merged, isLoadingMore = false, hasMore = true) + } + } + PaginationStatus.EndOfPagination -> mergedFlow.map { merged -> + if (merged.isEmpty()) { + HistoryState.Empty + } else { + HistoryState.Content(merged, isLoadingMore = false, hasMore = false) + } + } + } + } + + private fun Action.toBatchAction(): HistoryTxBatchAction = when (this) { + is Action.Reload -> BatchAction.Reload( + TxHistoryListConfig(userWalletId, currency, shouldRefresh = shouldRefresh), + ) + Action.LoadMore -> BatchAction.LoadMore(TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)) + } + + private fun oldestLoadedTimestamp(batchState: BatchListState>): Long = + batchState.data.asSequence() + .flatMap { it.data.items.asSequence() } + .minOfOrNull { it.timestampInMillis } + ?: NO_LOWER_BOUND + + private companion object { + const val BATCH_SIZE = 50 + const val NO_LOWER_BOUND = 0L + + /** Number of loaded items considered enough to make the list scrollable. */ + const val AUTO_LOAD_MORE_TARGET_COUNT = 20 + } + + @AssistedFactory + interface Factory { + fun create(env: HistoryEnvironment): BsdkOnChainHistory + } +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt new file mode 100644 index 0000000000..64be5c447e --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistory.kt @@ -0,0 +1,73 @@ +package com.tangem.data.txhistory.list.chain + +import com.tangem.data.txhistory.list.mergeTxHistoryInfos +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.repository.ExpressHistoryPage +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update + +/** + * Backbone for currencies without an on-chain history source: paginates the express (swap/onramp) overlay itself, + * using the unified history index. There is no on-chain leg, so every row is a standalone express item. + */ +internal class IndexTableOnChainHistory @AssistedInject constructor( + private val repository: TxHistoryRepositoryV2, + @Assisted private val env: HistoryEnvironment, +) : OnChainHistory { + + override val actions: Channel = Channel() + + override fun history(): Flow = channelFlow { + val limit = MutableStateFlow(PAGE_SIZE) + actions.receiveAsFlow() + .onEach { action -> + when (action) { + is Action.Reload -> limit.value = PAGE_SIZE + Action.LoadMore -> limit.update { it + PAGE_SIZE } + } + } + .launchIn(this) + + limit + .flatMapLatest { pageLimit -> + repository.getIndexedExpressHistory( + env.userWalletId, + env.currency, + pageLimit, + ) + } + .map { page -> buildState(page) } + .collect { send(it) } + } + + private fun buildState(page: ExpressHistoryPage): HistoryState { + val merged = mergeTxHistoryInfos(onChain = emptyList(), express = page.items) + return if (merged.isEmpty()) { + HistoryState.Empty + } else { + HistoryState.Content(merged, isLoadingMore = false, hasMore = page.hasMore) + } + } + + private companion object { + const val PAGE_SIZE = 50 + } + + @AssistedFactory + interface Factory { + fun create(env: HistoryEnvironment): IndexTableOnChainHistory + } +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/OnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/OnChainHistory.kt new file mode 100644 index 0000000000..d1ea6f44c8 --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/OnChainHistory.kt @@ -0,0 +1,20 @@ +package com.tangem.data.txhistory.list.chain + +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.pagination.BatchAction +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow + +/** One history backbone (BSDK / TangemPay / index-backed express): consumes [Action]s and emits [HistoryState]. */ +internal interface OnChainHistory { + val actions: Channel + fun sendAction(action: Action) = actions.trySend(action) + + fun history(): Flow +} + +/** Neutral external action, decoupled from the pagination [BatchAction]. */ +internal sealed interface Action { + data class Reload(val shouldRefresh: Boolean) : Action + data object LoadMore : Action +} \ No newline at end of file diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistory.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistory.kt new file mode 100644 index 0000000000..e61e4a240e --- /dev/null +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistory.kt @@ -0,0 +1,118 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.tangem.data.txhistory.list.chain + +import com.tangem.data.txhistory.list.mergeTangemPay +import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext +import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig +import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +private typealias TangemPayTxBatchAction = BatchAction + +/** On-chain backbone backed by the TangemPay tx history, with the express overlay windowed to the loaded page. */ +internal class TangemPayOnChainHistory @AssistedInject constructor( + private val repository: TangemPayTxHistoryRepository, + private val txHistoryRepository: TxHistoryRepositoryV2, + @Assisted private val env: HistoryEnvironment, +) : OnChainHistory { + private val userWalletId get() = env.userWalletId + private val currency get() = env.currency + + override val actions: Channel = Channel() + + override fun history(): Flow { + val actionsFlow = flow { + // initial load + emit(Action.Reload(shouldRefresh = false)) + // receive user actions + actions.receiveAsFlow().collect { emit(it) } + } + val batchFlow = repository.getTxHistoryBatchFlow( + userWalletId = userWalletId, + batchSize = BATCH_SIZE, + context = TangemPayTxHistoryListBatchingContext( + actionsFlow = actionsFlow.map { it.toBatchAction() }, + coroutineScope = env.modelScope, + ), + ) + + return batchFlow.state + .flatMapLatest { batchState -> buildState(batchState) } + } + + private fun buildState(batchState: BatchListState>): Flow { + val mergedFlow: Flow> = txHistoryRepository.getExpressHistory( + userWalletId = userWalletId, + currency = currency, + fromCreatedAtMillis = oldestLoadedTimestamp(batchState), + ).map { express -> + val onChain = batchState.data.asSequence() + .flatMap { it.data.asSequence() } + .map(OnChainTx::TangemPay) + .toList() + + mergeTangemPay(onChain = onChain, express = express) + } + + return when (batchState.status) { + PaginationStatus.None, + PaginationStatus.InitialLoading, + -> flowOf(HistoryState.Loading) + // Terminal initial failure: surface Error so the user can retry the on-chain page load. + is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error) + PaginationStatus.NextBatchLoading -> mergedFlow.map { merged -> + HistoryState.Content(merged, isLoadingMore = true, hasMore = true) + } + is PaginationStatus.Paginating<*> -> mergedFlow.map { merged -> + if (merged.isEmpty()) { + HistoryState.Empty + } else { + HistoryState.Content(merged, isLoadingMore = false, hasMore = true) + } + } + PaginationStatus.EndOfPagination -> mergedFlow.map { merged -> + if (merged.isEmpty()) { + HistoryState.Empty + } else { + HistoryState.Content(merged, isLoadingMore = false, hasMore = false) + } + } + } + } + + private fun Action.toBatchAction(): TangemPayTxBatchAction = when (this) { + is Action.Reload -> BatchAction.Reload(TangemPayTxHistoryListConfig(shouldRefresh = shouldRefresh)) + Action.LoadMore -> BatchAction.LoadMore(requestParams = null) + } + + private fun oldestLoadedTimestamp(batchState: BatchListState>): Long = + batchState.data.asSequence() + .flatMap { it.data.asSequence() } + .minOfOrNull { it.date.millis } + ?: NO_LOWER_BOUND + + private companion object { + const val BATCH_SIZE = 50 + const val NO_LOWER_BOUND = 0L + } + + @AssistedFactory + interface Factory { + fun create(env: HistoryEnvironment): TangemPayOnChainHistory + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManagerTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManagerTest.kt new file mode 100644 index 0000000000..ec9fd939eb --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/DefaultHistoryTxListManagerTest.kt @@ -0,0 +1,354 @@ +package com.tangem.data.txhistory.list + +import arrow.core.Either +import arrow.core.none +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.data.txhistory.list.chain.Action +import com.tangem.data.txhistory.list.chain.BsdkOnChainHistory +import com.tangem.data.txhistory.list.chain.IndexTableOnChainHistory +import com.tangem.data.txhistory.list.chain.TangemPayOnChainHistory +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.express.ExpressServiceFetcher +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistorySources +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.list.HistoryTxListManager.OnChainSource +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.test.core.ProvideTestModels +import com.tangem.test.core.getEmittedValues +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** + * Verifies the orchestration in [DefaultHistoryTxListManager]: which on-chain backbone is chosen, availability gating, + * the universal state sequence (initial load → error → retry → content), analytics on unexpected failures, and + * forwarding of external actions to the chosen backbone. The backbones themselves are stubbed (tested separately). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultHistoryTxListManagerTest { + + private val userWalletId = UserWalletId(stringValue = "01") + private val currency = mockk(relaxed = true) + + private val expressServiceFetcher = mockk() + private val paymentAccountCurrency = mockk() + private val getAccountCurrencyStatusUseCase = mockk() + private val txHistoryItemsCountUseCase = mockk() + private val analyticsExceptionHandler = mockk(relaxed = true) + + private val bsdk = mockk(relaxed = true) + private val tangemPay = mockk(relaxed = true) + private val indexTable = mockk(relaxed = true) + private val bsdkFactory = mockk() + private val tangemPayFactory = mockk() + private val indexTableFactory = mockk() + + @BeforeEach + fun setup() { + clearMocks( + expressServiceFetcher, + paymentAccountCurrency, + getAccountCurrencyStatusUseCase, + txHistoryItemsCountUseCase, + analyticsExceptionHandler, + bsdk, + tangemPay, + indexTable, + bsdkFactory, + tangemPayFactory, + indexTableFactory, + ) + every { bsdkFactory.create(any()) } returns bsdk + every { tangemPayFactory.create(any()) } returns tangemPay + every { indexTableFactory.create(any()) } returns indexTable + every { bsdk.history() } returns emptyFlow() + every { tangemPay.history() } returns emptyFlow() + every { indexTable.history() } returns emptyFlow() + + // Defaults: not a crypto portfolio, not a payment account, express available. + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns none() + coEvery { paymentAccountCurrency.invokeSync(any(), any()) } returns none() + coEvery { txHistoryItemsCountUseCase(any(), any()) } returns 5.right() + coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right() + } + + // region source resolution + + @ParameterizedTest + @ProvideTestModels + fun resolveOnChainSource(model: ResolutionModel) = runTest { + // Arrange + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + if (model.isCryptoPortfolio) mockk().some() else none() + model.itemsCount?.let { coEvery { txHistoryItemsCountUseCase(any(), any()) } returns it } + coEvery { paymentAccountCurrency.invokeSync(any(), any()) } returns + if (model.isPayment) (mockk() to mockk()).some() else none() + + // Act + val scope = runManager() + + // Assert + verify(exactly = if (model.expected == OnChainSource.BSDK) 1 else 0) { bsdkFactory.create(any()) } + verify(exactly = if (model.expected == OnChainSource.TangemPay) 1 else 0) { tangemPayFactory.create(any()) } + verify(exactly = if (model.expected == OnChainSource.IndexTable) 1 else 0) { indexTableFactory.create(any()) } + scope.cancel() + } + + private fun provideTestModels() = listOf( + ResolutionModel( + name = "crypto portfolio with tx history", + isCryptoPortfolio = true, + itemsCount = 5.right(), + expected = OnChainSource.BSDK, + ), + ResolutionModel( + name = "crypto portfolio with empty tx history", + isCryptoPortfolio = true, + itemsCount = Either.Left(TxHistoryStateError.EmptyTxHistories), + expected = OnChainSource.BSDK, + ), + ResolutionModel( + name = "crypto portfolio without tx history support", + isCryptoPortfolio = true, + itemsCount = Either.Left(TxHistoryStateError.TxHistoryNotImplemented), + expected = OnChainSource.IndexTable, + ), + ResolutionModel( + name = "payment account", + isCryptoPortfolio = false, + isPayment = true, + expected = OnChainSource.TangemPay, + ), + ResolutionModel( + name = "neither crypto nor payment", + isCryptoPortfolio = false, + isPayment = false, + expected = OnChainSource.IndexTable, + ), + ) + + // endregion + + // region availability + + @Test + fun `GIVEN index-table source and express unavailable WHEN loading THEN Unavailable`() = runTest { + // Arrange: no on-chain source and express not available → nothing to show. + coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = false, onramp = false).right() + + // Act + val states: List + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val manager = createManager(scope) + states = getEmittedValues(manager.state) + advanceUntilIdle() + + // Assert + assertThat(states.last()).isEqualTo(HistoryState.Unavailable) + verify(exactly = 0) { indexTableFactory.create(any()) } + scope.cancel() + } + + // endregion + + // region state sequence + + @Test + fun `GIVEN backbone emits content WHEN loading THEN Loading then Content`() = runTest { + // Arrange: BSDK source that emits a single content page. + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + mockk().some() + every { bsdk.history() } returns flowOf(content()) + + // Act + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val states = getEmittedValues(createManager(scope).state) + advanceUntilIdle() + + // Assert + assertThat(states.first()).isEqualTo(HistoryState.Loading) + assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java) + scope.cancel() + } + + @Test + fun `GIVEN load fails WHEN retried from UI THEN Error then Loading then Content`() = runTest { + // Arrange: first load throws (DataError), retry succeeds with a content page. + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + mockk().some() + coEvery { txHistoryItemsCountUseCase(any(), any()) } returnsMany listOf( + Either.Left(TxHistoryStateError.DataError(RuntimeException("boom"))), + 5.right(), + ) + every { bsdk.history() } returns flowOf(content()) + + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val manager = createManager(scope) + val states = getEmittedValues(manager.state) + advanceUntilIdle() + + // Act: retry from the UI layer after the full-screen error. + manager.reload() + advanceUntilIdle() + + // Assert + assertThat(states).containsExactly( + HistoryState.Loading, + HistoryState.Error, + HistoryState.Loading, + content(), + ).inOrder() + scope.cancel() + } + + @Test + fun `GIVEN backbone throws unexpectedly WHEN collecting THEN exception reported and pipeline recovers`() = runTest { + // Arrange: BSDK source; the history flow throws once, then recovers on the retry. + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + mockk().some() + every { bsdk.history() } returnsMany listOf( + flow { throw RuntimeException("stream boom") }, + flowOf(content()), + ) + + // Act + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val states = getEmittedValues(createManager(scope).state) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { analyticsExceptionHandler.sendException(any()) } + assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java) + scope.cancel() + } + + // endregion + + // region historySources & action forwarding + + @Test + fun `GIVEN successful load WHEN observed THEN historySources emitted`() = runTest { + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + mockk().some() + coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right() + every { bsdk.history() } returns MutableStateFlow(content()) + + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val sources = getEmittedValues(createManager(scope).historySources) + advanceUntilIdle() + + assertThat(sources).containsExactly( + HistorySources(onChainSource = OnChainSource.BSDK, isExchangeAvailable = true, isOnrampAvailable = false), + ) + scope.cancel() + } + + @Test + fun `GIVEN loaded backbone WHEN reload and loadMore THEN forwarded to the backbone`() = runTest { + coEvery { getAccountCurrencyStatusUseCase.invokeSync(any(), any()) } returns + mockk().some() + // Keep the backbone open so the forwarding collector stays active. + every { bsdk.history() } returns MutableStateFlow(content()) + + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val manager = createManager(scope) + getEmittedValues(manager.state) + advanceUntilIdle() + + // Act — the actions channel is rendezvous, so let each action be consumed before sending the next. + manager.reload() + advanceUntilIdle() + manager.loadMore() + advanceUntilIdle() + + // Assert + verify { bsdk.sendAction(Action.Reload(shouldRefresh = true)) } + verify { bsdk.sendAction(Action.LoadMore) } + scope.cancel() + } + + // endregion + + private fun TestScope.runManager(): CoroutineScope { + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val manager = createManager(scope) + getEmittedValues(manager.state) + advanceUntilIdle() + return scope + } + + private fun TestScope.createManager(modelScope: CoroutineScope) = DefaultHistoryTxListManager( + dispatchers = testDispatchers(), + expressServiceFetcher = expressServiceFetcher, + paymentAccountCurrency = paymentAccountCurrency, + getAccountCryptoCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + txHistoryItemsCountUseCase = txHistoryItemsCountUseCase, + analyticsExceptionHandler = analyticsExceptionHandler, + bsdkOnChainHistoryFactory = bsdkFactory, + tangemPayOnChainHistoryFactory = tangemPayFactory, + indexTableOnChainHistoryFactory = indexTableFactory, + userWalletId = userWalletId, + currency = currency, + modelScope = modelScope, + ) + + private fun TestScope.testDispatchers(): CoroutineDispatcherProvider { + val dispatcher: CoroutineDispatcher = StandardTestDispatcher(testScheduler) + return object : CoroutineDispatcherProvider { + override val main = dispatcher + override val mainImmediate = dispatcher + override val io = dispatcher + override val default = dispatcher + override val single = dispatcher + } + } + + private fun content() = HistoryState.Content(items = emptyList(), isLoadingMore = false, hasMore = false) + + private fun asset(exchange: Boolean, onramp: Boolean) = ExpressAsset( + id = ExpressAsset.ID(networkId = "eth", contractAddress = "0"), + isExchangeAvailable = exchange, + isOnrampAvailable = onramp, + ) + + internal data class ResolutionModel( + val name: String, + val isCryptoPortfolio: Boolean, + val itemsCount: Either? = null, + val isPayment: Boolean = false, + val expected: OnChainSource, + ) { + override fun toString(): String = name + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt similarity index 99% rename from features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt rename to data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt index 039ed35ed6..bc2a4ac207 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/TxHistoryInfoMergerTest.kt @@ -1,4 +1,4 @@ -package com.tangem.features.txhistory.utils +package com.tangem.data.txhistory.list import com.google.common.truth.Truth.assertThat import com.tangem.domain.express.models.ExchangeTransaction diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt new file mode 100644 index 0000000000..f400255ebe --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/BsdkOnChainHistoryTest.kt @@ -0,0 +1,250 @@ +package com.tangem.data.txhistory.list.chain + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow +import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext +import com.tangem.domain.txhistory.models.Page +import com.tangem.domain.txhistory.models.PaginationWrapper +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.test.core.getEmittedValues +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Verifies the BSDK backbone: the pagination status maps to [HistoryState], the express overlay is merged in, and + * [BsdkOnChainHistory.autoLoadMoreUntilScrollable] dispatches a `LoadMore` while the loaded list is too short to + * scroll (fewer than the target) or the last page was empty, and stops otherwise. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class BsdkOnChainHistoryTest { + + private val userWalletId = UserWalletId(stringValue = "01") + private val currency = mockk(relaxed = true) + private val repository = mockk() + + // region state mapping + + @Test + fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest { + val states = collect(state(items = 0, status = PaginationStatus.InitialLoading)) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Loading) + } + + @Test + fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest { + val states = collect(state(items = 0, status = PaginationStatus.InitialLoadingError(RuntimeException("boom")))) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Error) + } + + @Test + fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest { + val states = collect(state(items = 0, status = PaginationStatus.EndOfPagination)) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Empty) + } + + @Test + fun `GIVEN items and more to load WHEN loading THEN Content with hasMore true`() = runTest { + val states = collect(state(items = 25, status = paginating(empty = false))) + advanceUntilIdle() + + val last = states.last() + assertThat(last).isInstanceOf(HistoryState.Content::class.java) + with(last as HistoryState.Content) { + assertThat(items).hasSize(25) + assertThat(hasMore).isTrue() + assertThat(isLoadingMore).isFalse() + } + } + + @Test + fun `GIVEN items and end reached WHEN loading THEN Content with hasMore false`() = runTest { + val states = collect(state(items = 25, status = PaginationStatus.EndOfPagination)) + advanceUntilIdle() + + assertThat((states.last() as HistoryState.Content).hasMore).isFalse() + } + + @Test + fun `GIVEN express op matched to a loaded on-chain tx WHEN loading THEN row is enriched`() = runTest { + val states = collect( + batchState = state(items = 25, status = PaginationStatus.EndOfPagination, firstTxHash = "match"), + express = listOf(createSwap(matchHash = "match")), + ) + advanceUntilIdle() + + val enriched = (states.last() as HistoryState.Content).items.filterIsInstance().single() + assertThat(enriched.txInfo).isInstanceOf(OnChainTx.BSDK::class.java) + } + + // endregion + + // region auto-load + + @Test + fun `GIVEN a short page with more to load WHEN paginating THEN a LoadMore is dispatched`() = runTest { + val actions = collectDispatchedActions(state(items = 10, status = paginating(empty = false))) + advanceUntilIdle() + + assertThat(actions.filterIsInstance>()).isNotEmpty() + } + + @Test + fun `GIVEN a page long enough to scroll WHEN paginating THEN no LoadMore is dispatched`() = runTest { + val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = false))) + advanceUntilIdle() + + assertThat(actions.filterIsInstance>()).isEmpty() + } + + @Test + fun `GIVEN the last page came back empty WHEN paginating THEN a LoadMore is dispatched`() = runTest { + // Enough items to be scrollable, but the last fetch was empty (a gap) → keep bridging to the end. + val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = true))) + advanceUntilIdle() + + assertThat(actions.filterIsInstance>()).isNotEmpty() + } + + // endregion + + private fun TestScope.collect( + batchState: BatchListState>, + express: List = emptyList(), + ): List { + stubRepository(batchState, express) + return getEmittedValues(createSut().history()) + } + + /** + * Collects the [BatchAction]s the SUT dispatches to the pagination source, to assert the auto-load decision. + * + * The actions channel is rendezvous, so the source-side collector must be parked before the auto-load check + * fires its `LoadMore`: we start collecting the captured `actionsFlow` (and let it park past the initial `Reload`) + * before collecting `history()`, which is what triggers the auto-load check. + */ + private fun TestScope.collectDispatchedActions( + batchState: BatchListState>, + ): List> { + val contextSlot = slot() + stubRepository(batchState, express = emptyList(), contextSlot = contextSlot) + + val historyFlow = createSut().history() // captures the context synchronously + val actions = getEmittedValues(contextSlot.captured.actionsFlow) + advanceUntilIdle() // the source-side collector emits the initial Reload, then parks + getEmittedValues(historyFlow) // triggers the auto-load check, which may dispatch a LoadMore + return actions + } + + private fun stubRepository( + batchState: BatchListState>, + express: List, + contextSlot: io.mockk.CapturingSlot? = null, + ) { + val batchFlow = mockk { every { state } returns MutableStateFlow(batchState) } + if (contextSlot != null) { + every { repository.getTxHistoryBatchFlow(any(), capture(contextSlot)) } returns batchFlow + } else { + every { repository.getTxHistoryBatchFlow(any(), any()) } returns batchFlow + } + every { repository.getExpressHistory(any(), any(), any()) } returns flowOf(express) + } + + private fun TestScope.createSut() = BsdkOnChainHistory( + repository = repository, + env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope), + ) + + private fun state( + items: Int, + status: PaginationStatus>, + firstTxHash: String? = null, + ): BatchListState> { + val wrapper = PaginationWrapper( + currentPage = Page.Initial, + nextPage = Page.LastPage, + items = List(items) { index -> + val hash = if (index == 0 && firstTxHash != null) firstTxHash else "hash-$index" + createTxInfo(txHash = hash) + }, + ) + return BatchListState( + data = if (items == 0) emptyList() else listOf(Batch(key = 0, data = wrapper)), + status = status, + ) + } + + private fun paginating(empty: Boolean): PaginationStatus> { + val wrapper = PaginationWrapper(Page.Initial, Page.LastPage, items = emptyList()) + return PaginationStatus.Paginating(BatchFetchResult.Success(data = wrapper, empty = empty, last = false)) + } + + private fun createTxInfo(txHash: String) = TxInfo( + txHash = txHash, + timestampInMillis = 100, + isOutgoing = true, + destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")), + sourceType = TxInfo.SourceType.Single("addr"), + interactionAddressType = null, + status = TxInfo.TransactionStatus.Confirmed, + type = TxInfo.TransactionType.Transfer, + amount = BigDecimal.ONE, + ) + + private fun createSwap(matchHash: String) = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = ExpressExchangeStatus.Finished, + createdAtMillis = 100, + provider = null, + payinHash = matchHash, + payoutHash = null, + fromAddress = null, + payoutAddress = null, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = BigDecimal.ONE, + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = BigDecimal.ONE, + decimals = 8, + ), + ), + isOutgoing = true, + txInfo = null, + ) +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt new file mode 100644 index 0000000000..d954ccab3b --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/IndexTableOnChainHistoryTest.kt @@ -0,0 +1,142 @@ +package com.tangem.data.txhistory.list.chain + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.repository.ExpressHistoryPage +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.test.core.getEmittedValues +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Verifies the index-backed express backbone (used when a currency has no on-chain history source): it maps an + * [ExpressHistoryPage] to [HistoryState] and grows/reset the paging window on `LoadMore` / `Reload`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class IndexTableOnChainHistoryTest { + + private val userWalletId = UserWalletId(stringValue = "01") + private val currency = mockk(relaxed = true) + private val repository = mockk() + + @BeforeEach + fun setup() { + clearMocks(repository) + } + + @Test + fun `GIVEN empty page WHEN loading THEN Empty`() = runTest { + every { repository.getIndexedExpressHistory(any(), any(), any()) } returns + flowOf(ExpressHistoryPage(items = emptyList(), hasMore = false)) + + val states = getEmittedValues(createSut().history()) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Empty) + } + + @Test + fun `GIVEN non-empty page with more WHEN loading THEN Content with hasMore true`() = runTest { + every { repository.getIndexedExpressHistory(any(), any(), any()) } returns + flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true)) + + val states = getEmittedValues(createSut().history()) + advanceUntilIdle() + + val last = states.last() + assertThat(last).isInstanceOf(HistoryState.Content::class.java) + with(last as HistoryState.Content) { + assertThat(items).hasSize(1) + assertThat(hasMore).isTrue() + assertThat(isLoadingMore).isFalse() + } + } + + @Test + fun `GIVEN loaded page WHEN loadMore THEN window grows by page size`() = runTest { + every { repository.getIndexedExpressHistory(any(), any(), any()) } returns + flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true)) + + val sut = createSut() + getEmittedValues(sut.history()) + advanceUntilIdle() + + sut.sendAction(Action.LoadMore) + advanceUntilIdle() + + verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) } + verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE * 2) } + } + + @Test + fun `GIVEN grown window WHEN reload THEN window resets to page size`() = runTest { + every { repository.getIndexedExpressHistory(any(), any(), any()) } returns + flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true)) + + val sut = createSut() + getEmittedValues(sut.history()) + advanceUntilIdle() + sut.sendAction(Action.LoadMore) + advanceUntilIdle() + clearMocks(repository, answers = false) + + sut.sendAction(Action.Reload(shouldRefresh = true)) + advanceUntilIdle() + + verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) } + } + + private fun TestScope.createSut() = IndexTableOnChainHistory( + repository = repository, + env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope), + ) + + private fun createSwap() = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = ExpressExchangeStatus.Waiting, + createdAtMillis = 100, + provider = null, + payinHash = null, + payoutHash = null, + fromAddress = null, + payoutAddress = null, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = BigDecimal.ONE, + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = BigDecimal.ONE, + decimals = 8, + ), + ), + isOutgoing = true, + txInfo = null, + ) + + private companion object { + const val PAGE_SIZE = 50 + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt new file mode 100644 index 0000000000..6e93235a99 --- /dev/null +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/list/chain/TangemPayOnChainHistoryTest.kt @@ -0,0 +1,177 @@ +package com.tangem.data.txhistory.list.chain + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow +import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListState +import com.tangem.pagination.PaginationStatus +import com.tangem.test.core.getEmittedValues +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.util.Currency + +/** + * Verifies the TangemPay backbone: items map to [OnChainTx.TangemPay], the pagination status maps to [HistoryState], + * and the express overlay is merged in by hash. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayOnChainHistoryTest { + + private val userWalletId = UserWalletId(stringValue = "01") + private val currency = mockk(relaxed = true) + private val repository = mockk() + private val txHistoryRepository = mockk() + + @Test + fun `GIVEN loaded items and end WHEN loading THEN Content of TangemPay rows`() = runTest { + val payment = createPayment(id = "p1", transactionHash = "h1") + val states = collect( + batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination), + express = emptyList(), + ) + advanceUntilIdle() + + val last = states.last() + assertThat(last).isInstanceOf(HistoryState.Content::class.java) + with(last as HistoryState.Content) { + assertThat(hasMore).isFalse() + assertThat(items.single()).isInstanceOf(OnChainTx.TangemPay::class.java) + } + } + + @Test + fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest { + val states = collect( + batchState = batchListState(items = emptyList(), status = PaginationStatus.InitialLoading), + express = emptyList(), + ) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Loading) + } + + @Test + fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest { + val states = collect( + batchState = batchListState( + items = emptyList(), + status = PaginationStatus.InitialLoadingError(throwable = RuntimeException("boom")), + ), + express = emptyList(), + ) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Error) + } + + @Test + fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest { + val states = collect( + batchState = batchListState(items = emptyList(), status = PaginationStatus.EndOfPagination), + express = emptyList(), + ) + advanceUntilIdle() + + assertThat(states.last()).isEqualTo(HistoryState.Empty) + } + + @Test + fun `GIVEN express op matching a TangemPay hash WHEN loading THEN row is enriched`() = runTest { + val payment = createPayment(id = "p1", transactionHash = "match") + val express = listOf(createSwap(matchHash = "match")) + val states = collect( + batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination), + express = express, + ) + advanceUntilIdle() + + val items = (states.last() as HistoryState.Content).items + val enriched = items.filterIsInstance().single() + assertThat(enriched.txInfo).isInstanceOf(OnChainTx.TangemPay::class.java) + } + + private fun TestScope.collect( + batchState: BatchListState>, + express: List, + ): List { + val batchFlow = mockk { + every { state } returns MutableStateFlow(batchState) + } + every { repository.getTxHistoryBatchFlow(any(), any(), any()) } returns batchFlow + every { txHistoryRepository.getExpressHistory(any(), any(), any()) } returns flowOf(express) + + val sut = TangemPayOnChainHistory( + repository = repository, + txHistoryRepository = txHistoryRepository, + env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope), + ) + return getEmittedValues(sut.history()) + } + + private fun batchListState( + items: List, + status: PaginationStatus>, + ) = BatchListState( + data = if (items.isEmpty()) emptyList() else listOf(Batch(key = 0, data = items)), + status = status, + ) + + private fun createPayment(id: String, transactionHash: String) = TangemPayTxHistoryItem.Payment( + id = id, + jsonRepresentation = "", + date = DateTime(100L), + amount = BigDecimal.ONE, + currency = Currency.getInstance("USD"), + transactionHash = transactionHash, + ) + + private fun createSwap(matchHash: String) = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = ExpressExchangeStatus.Finished, + createdAtMillis = 100, + provider = null, + payinHash = matchHash, + payoutHash = null, + fromAddress = null, + payoutAddress = null, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = BigDecimal.ONE, + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = BigDecimal.ONE, + decimals = 8, + ), + ), + isOutgoing = true, + txInfo = null, + ) +} \ No newline at end of file diff --git a/domain/txhistory/build.gradle.kts b/domain/txhistory/build.gradle.kts index 19711a8331..ed57e1bd33 100644 --- a/domain/txhistory/build.gradle.kts +++ b/domain/txhistory/build.gradle.kts @@ -27,5 +27,6 @@ dependencies { api(projects.domain.express.models) api(projects.domain.models) api(projects.domain.txhistory.models) + api(projects.domain.visa.models) // endregion } \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/list/HistoryTxListManager.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/list/HistoryTxListManager.kt new file mode 100644 index 0000000000..b3b196d81f --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/list/HistoryTxListManager.kt @@ -0,0 +1,74 @@ +package com.tangem.domain.txhistory.list + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState +import com.tangem.domain.txhistory.model.TxHistoryInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* + +/** + * Reads the unified transaction history for a currency: the on-chain backbone (BSDK / TangemPay / index-backed + + * (wallet, currency) via [Factory]. + */ +interface HistoryTxListManager { + + /** The whole history state, newest first — the single source of truth for the UI. */ + val state: StateFlow + + val historySources: Flow + + fun reload() + + fun loadMore() + + /** Universal history state: the caller renders exactly one of these. */ + sealed interface HistoryState { + data object Loading : HistoryState + data object Unavailable : HistoryState + data object Empty : HistoryState + data object Error : HistoryState + data class Content( + val items: List, + val isLoadingMore: Boolean, + val hasMore: Boolean, + ) : HistoryState + } + + /** How the history starts for a currency: which on-chain backbone exists and whether express is available. */ + data class HistorySources( + val onChainSource: OnChainSource, + val isExchangeAvailable: Boolean, + val isOnrampAvailable: Boolean, + ) + + data class HistoryEnvironment( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val modelScope: CoroutineScope, + ) + + enum class OnChainSource { BSDK, TangemPay, IndexTable } + + interface Factory { + fun create( + userWalletId: UserWalletId, + currency: CryptoCurrency, + modelScope: CoroutineScope, + ): HistoryTxListManager + } +} + +/** + * Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet. + * + * Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from + * the live merged list as its status changes. The seed also covers rows not present in [state] yet (e.g. a pending + * tx surfaced from the currency status), which would otherwise never resolve. + */ +fun HistoryTxListManager.txHistoryInfoFlow(item: TxHistoryInfo): Flow = state + .mapNotNull { (it as? HistoryState.Content)?.items } + .mapNotNull { list -> list.firstOrNull { it.txId == item.txId } } + .onStart { emit(item) } + .distinctUntilChanged() \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt index 0c159ba5be..7133ff6c1e 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt @@ -4,6 +4,7 @@ import com.tangem.domain.express.models.ExchangeTransaction import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.OnrampTransaction import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.visa.model.TangemPayTxHistoryItem /** * A single row of the unified transaction history shown on the token-details screen. @@ -38,11 +39,10 @@ sealed interface OnChainTx : TxHistoryInfo { override val timestampMillis: Long get() = txInfo.timestampInMillis } - // todo txHistory next step - /*data class TangemPay(val txInfo: TangemPayTxHistoryItem) : OnChainTx { + data class TangemPay(val txInfo: TangemPayTxHistoryItem) : OnChainTx { override val txId: String get() = txInfo.id override val timestampMillis: Long get() = txInfo.date.millis - }*/ + } // todo txHistory next step /*data class Gateway() : OnChainTx { @@ -71,6 +71,13 @@ fun TxInfo.identityKey(): String = "$txHash|$type" inline val TxHistoryInfo.explorerHash: String? get() = when (this) { is OnChainTx.BSDK -> txInfo.txHash + is OnChainTx.TangemPay -> when (val item = txInfo) { + is TangemPayTxHistoryItem.Payment -> item.transactionHash + is TangemPayTxHistoryItem.Collateral -> item.transactionHash + is TangemPayTxHistoryItem.Spend, + is TangemPayTxHistoryItem.Fee, + -> null + } is ExpressTx -> matchHash } @@ -78,6 +85,7 @@ inline val TxHistoryInfo.explorerHash: String? inline val TxHistoryInfo.idToCopy: String get() = when (this) { is OnChainTx.BSDK -> txInfo.txHash + is OnChainTx.TangemPay -> explorerHash ?: txId is ExpressTx -> txId } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt index 9540d6d899..fe1e72f7c2 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -32,5 +32,7 @@ internal class TxHistoryInfoToTransactionItemUMConverter( is TransactionItemUM.Pill -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) }) else -> um } + // todo txHistory: render standalone TangemPay on-chain rows when TangemPay is wired into the history + is OnChainTx.TangemPay -> TODO("TangemPay on-chain row rendering is not implemented yet") } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt index 2cb19157fc..20ddaf3c14 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -51,6 +51,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { is OnChainTx.BSDK -> onChainConverter.convert(value.txInfo) + // todo txHistory: build the details card for standalone TangemPay rows when TangemPay is wired in + is OnChainTx.TangemPay -> TODO("TangemPay on-chain details rendering is not implemented yet") is ExpressTx -> expressConverter.convert(value) } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index 13b5bac325..d90ad6861b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -15,6 +15,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger +import com.tangem.domain.txhistory.list.HistoryTxListManager +import com.tangem.domain.txhistory.list.txHistoryInfoFlow import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.explorerHash import com.tangem.domain.txhistory.models.TxHistoryStateError @@ -30,9 +32,7 @@ import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.features.txhistory.entity.TxHistoryUpdateListener import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot import com.tangem.features.txhistory.state.TxHistoryStateController -import com.tangem.features.txhistory.utils.HistoryTxListManager -import com.tangem.features.txhistory.utils.TxHistoryListManager -import com.tangem.features.txhistory.utils.TxHistoryUiActions +import com.tangem.features.txhistory.utils.* import com.tangem.pagination.PaginationStatus import com.tangem.utils.annotations.RemoveWithToggle import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -99,6 +99,7 @@ internal class TxHistoryModel @Inject constructor( historyTxListManagerFactory.create( userWalletId = params.userWalletId, currency = params.currency, + modelScope = modelScope, ) } else { null @@ -133,23 +134,34 @@ internal class TxHistoryModel @Inject constructor( if (historyTxListManager != null) { combine( - flow = historyTxListManager.items, + flow = historyTxListManager.state, flow2 = lookupDataFlow, - transform = { merged, lookup -> merged to lookup }, + transform = { state, lookup -> state to lookup }, ) - .onEach { (merged, lookup) -> - stateController.setContent( - snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(merged, lookup)), - loadMore = ::loadMoreItems, - onExploreClick = ::openExplorer, - ) - } + .onEach { (state, lookup) -> applyHistoryState(state, lookup) } .flowOn(dispatchers.default) .launchIn(modelScope) + } + } - historyTxListManager.paginationStatus - .onEach { paginationStatus -> handlePaginationStatus(paginationStatus) } - .launchIn(modelScope) + private fun applyHistoryState(state: HistoryTxListManager.HistoryState, lookup: TxHistoryLookupContext) { + when (state) { + HistoryTxListManager.HistoryState.Loading -> + stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer) + HistoryTxListManager.HistoryState.Unavailable -> + stateController.setNotSupported(onExploreClick = ::openExplorer) + HistoryTxListManager.HistoryState.Empty -> + stateController.setEmpty(onExploreClick = ::openExplorer) + HistoryTxListManager.HistoryState.Error -> + stateController.setError(onReloadClick = ::reload, onExploreClick = ::openExplorer) + is HistoryTxListManager.HistoryState.Content -> { + stateController.setContent( + snapshot = TxHistoryItemsSnapshot.Items(buildUiItems(state.items, lookup)), + loadMore = ::loadMoreItems, + onExploreClick = ::openExplorer, + ) + stateController.updateLoadingMore(isLoadingMore = state.isLoadingMore) + } } } @@ -192,19 +204,17 @@ internal class TxHistoryModel @Inject constructor( private fun initListManager() { modelScope.launch { txHistoryListManager?.init() - historyTxListManager?.init() } } private fun loadTxInfo() { stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer) modelScope.launch { - txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) - .onLeft(::handleErrorState) - .onRight { - txHistoryListManager?.startLoading() - historyTxListManager?.startLoading() - } + txHistoryListManager?.let { legacy -> + txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) + .onLeft(::handleErrorState) + .onRight { legacy.startLoading() } + } } if (txHistoryFeatureToggle.isNewTxHistoryEnabled) { val trigger = TxHistoryFetchTrigger.TokenDetailsOpen( @@ -219,13 +229,13 @@ internal class TxHistoryModel @Inject constructor( if (stateController.isNotSupported) return stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer) + historyTxListManager?.reload() modelScope.launch { - txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) - .onLeft(::handleErrorState) - .onRight { - txHistoryListManager?.reload() - historyTxListManager?.reload() - } + txHistoryListManager?.let { legacy -> + txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency) + .onLeft(::handleErrorState) + .onRight { legacy.reload() } + } if (txHistoryFeatureToggle.isNewTxHistoryEnabled) { val trigger = TxHistoryFetchTrigger.TokenDetailsPTR( walletId = params.userWalletId, @@ -245,9 +255,9 @@ internal class TxHistoryModel @Inject constructor( } private fun loadMoreItems(): Boolean { + historyTxListManager?.loadMore() modelScope.launch { txHistoryListManager?.loadMore(params.userWalletId, params.currency) - historyTxListManager?.loadMore(params.userWalletId, params.currency) } return true } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt deleted file mode 100644 index fe82f69948..0000000000 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.tangem.features.txhistory.utils - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.txhistory.model.* -import com.tangem.domain.txhistory.models.PaginationWrapper -import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 -import com.tangem.pagination.BatchAction -import com.tangem.pagination.BatchListState -import com.tangem.pagination.PaginationStatus -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* - -private typealias HistoryTxBatchAction = BatchAction - -/** - * Redesign-only transaction-history pipeline that merges the on-chain pagination backbone with the - * express (swap/onramp) overlay. Unlike [TxHistoryListManager] there is no legacy branch. - * - * The express overlay is asset-scoped and time-windowed to the oldest loaded on-chain timestamp - * (re-subscribed via [flatMapLatest] as more pages load) and re-emits live as the express DB updates, - * so status changes render without depending on the transaction count. - */ -@Suppress("LongParameterList") -internal class HistoryTxListManager @AssistedInject constructor( - private val repository: TxHistoryRepositoryV2, - private val dispatchers: CoroutineDispatcherProvider, - @Assisted private val userWalletId: UserWalletId, - @Assisted private val currency: CryptoCurrency, -) { - - private val jobHolder = JobHolder() - private val actionsFlow: MutableSharedFlow = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val state: MutableStateFlow = MutableStateFlow(State()) - - val items: Flow> = state - .filter { it.hasContent } - .map { it.items } - .distinctUntilChanged() - - val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() - - /** - * Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet. - * - * Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from - * the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending - * tx surfaced from the currency status), which would otherwise never resolve. - */ - fun txHistoryInfoFlow(item: TxHistoryInfo): Flow = items - .mapNotNull { list -> list.firstOrNull { it.txId == item.txId } } - .onStart { emit(item) } - .distinctUntilChanged() - - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun init() { - coroutineScope { - val batchFlow = repository.getTxHistoryBatchFlow( - context = TxHistoryListBatchingContext(actionsFlow = actionsFlow, coroutineScope = this), - batchSize = BATCH_SIZE, - ) - - val sharedBatchState = batchFlow.state.shareIn(scope = this, started = SharingStarted.Eagerly, replay = 1) - - val expressFlow = sharedBatchState - .map(::oldestLoadedTimestamp) - .distinctUntilChanged() - .flatMapLatest { fromCreatedAtMillis -> - repository.getExpressHistory(userWalletId, currency, fromCreatedAtMillis) - } - // Let the merge run on the first on-chain emission before the express query resolves. - .onStart { emit(emptyList()) } - - combine(sharedBatchState, expressFlow) { batchState, express -> - buildState(batchState, express) - } - .flowOn(dispatchers.default) - .onEach { state.value = it } - .launchIn(scope = this) - .saveIn(jobHolder) - } - } - - suspend fun startLoading() { - actionsFlow.emit( - BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)), - ) - } - - suspend fun reload() { - actionsFlow.emit( - BatchAction.Reload(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = true)), - ) - } - - suspend fun loadMore(userWalletId: UserWalletId, currency: CryptoCurrency) { - actionsFlow.emit( - BatchAction.LoadMore(requestParams = TxHistoryListConfig(userWalletId, currency, shouldRefresh = false)), - ) - } - - private fun buildState( - batchState: BatchListState>, - express: List, - ): State { - val onChain = batchState.data.asSequence() - .flatMap { it.data.items.asSequence() } - .distinctBy(TxInfo::identityKey) - .toList() - - val merged = mergeTxHistoryInfos(onChain = onChain, express = express) - - return State(status = batchState.status, items = merged) - } - - private fun oldestLoadedTimestamp(batchState: BatchListState>): Long = - batchState.data.asSequence() - .flatMap { it.data.items.asSequence() } - .minOfOrNull { it.timestampInMillis } - ?: NO_LOWER_BOUND - - private data class State( - val status: PaginationStatus<*> = PaginationStatus.None, - val items: List = emptyList(), - ) { - val hasContent: Boolean - get() = status !is PaginationStatus.None && - status !is PaginationStatus.InitialLoading && - status !is PaginationStatus.InitialLoadingError - } - - private companion object { - const val BATCH_SIZE = 50 - const val NO_LOWER_BOUND = 0L - } - - @AssistedFactory - interface Factory { - fun create(userWalletId: UserWalletId, currency: CryptoCurrency): HistoryTxListManager - } -} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt deleted file mode 100644 index 1830b1be74..0000000000 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.features.txhistory.utils - -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.txhistory.model.ExpressTx -import com.tangem.domain.txhistory.model.OnChainTx -import com.tangem.domain.txhistory.model.TxHistoryInfo - -/** - * Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single - * timestamp-DESC timeline. - * - * Per express op (matched to on-chain by [ExpressTx.matchHash]): - * - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es) - * of that hash are collapsed into this row (not emitted standalone). - * - unmatched → standalone row (status shown, no on-chain leg). Both in-progress and terminal - * (finished/failed) express ops are shown so the user always sees their deals. - * - * On-chain transactions that no express op claimed pass through as [OnChainTx]. - * [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller. - */ -internal fun mergeTxHistoryInfos(onChain: List, express: List): List { - val onChainByHash = onChain.associateBy { it.txHash } - val matchedHashes = mutableSetOf() - val result = mutableListOf() - - express.forEach { op -> - val matched = op.matchHash?.let(onChainByHash::get) - if (matched != null) { - result += op.withMatchedTxInfo(matched) - matchedHashes += matched.txHash - } else { - result += op - } - } - - onChain.forEach { tx -> - if (tx.txHash !in matchedHashes) { - result += OnChainTx.BSDK(tx) - } - } - - return result.sortedByDescending(TxHistoryInfo::timestampMillis) -} - -private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx { - val matched = OnChainTx.BSDK(txInfo) - return when (this) { - is ExpressTx.Swap -> copy(txInfo = matched) - is ExpressTx.Onramp -> copy(txInfo = matched) - } -} \ No newline at end of file