Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-28 17:24:23 +04:00
commit e6ec52e35a
83 changed files with 1846 additions and 301 deletions

View file

@ -21,6 +21,8 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod
import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.*
import com.tangem.domain.wallets.R
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
@ -126,6 +128,8 @@ internal class DefaultUserWalletsListRepository(
canOverride: Boolean,
): Either<SaveWalletError, UserWallet> = either {
if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) {
// the wallet was rebuilt from a fresh scan — reconcile the stored card state before rejecting
(userWallet as? UserWallet.Cold)?.let { refreshStoredCardState(scanResponse = it.scanResponse) }
raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved))
}
@ -321,6 +325,8 @@ internal class DefaultUserWalletsListRepository(
raise(UnlockWalletError.ScannedCardWalletNotMatched)
}
refreshStoredCardState(scanResponse)
val encryptionKey = UserWalletEncryptionKey(
walletId = userWallet.walletId,
encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty),
@ -448,6 +454,49 @@ internal class DefaultUserWalletsListRepository(
}
}
/**
* Refreshes the persisted card state of an already saved wallet from a freshly scanned card.
*
* Heals a stale backup status e.g. when backup was finalized on another device or the app was
* terminated before the post-backup update was persisted. A scan of the same physical card is
* the ground truth and is applied as is. A scan of another card of the same wallet refreshes the
* state too, except when the stored card is [CardDTO.BackupStatus.CardLinked] its backup is in
* progress, so the status is preserved until the same card is scanned again.
*/
private suspend fun refreshStoredCardState(scanResponse: ScanResponse) {
val walletId = UserWalletIdBuilder.scanResponse(scanResponse).build() ?: return
val storedWallet = userWallets.value?.find { it.walletId == walletId } as? UserWallet.Cold ?: return
val storedCard = storedWallet.scanResponse.card
val scannedCard = scanResponse.card
val isUpToDate = storedCard.backupStatus == scannedCard.backupStatus &&
storedCard.isAccessCodeSet == scannedCard.isAccessCodeSet
if (isUpToDate) return
// another card of the wallet must not override the stored card's in-progress backup state
val isAnotherCard = storedCard.cardId != scannedCard.cardId
val isBackupInProgress = storedCard.backupStatus is CardDTO.BackupStatus.CardLinked
if (isAnotherCard && isBackupInProgress) return
val updatedWallet = storedWallet.copy(
scanResponse = storedWallet.scanResponse.copy(
card = storedCard.copy(
backupStatus = scannedCard.backupStatus,
isAccessCodeSet = scannedCard.isAccessCodeSet,
),
),
)
if (savePersistentInformation()) {
publicInformationRepository.save(updatedWallet, canOverride = true)
}
updateWallets { wallets ->
wallets?.addOrReplace(updatedWallet) { it.walletId == walletId }
}
}
private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded(
newUserWallet: UserWallet,
oldUserWallet: UserWallet,

View file

@ -3,13 +3,18 @@ package com.tangem.tap.domain.userWalletList.repository
import com.google.common.truth.Truth.assertThat
import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemError
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
@ -144,4 +149,162 @@ internal class DefaultUserWalletsListRepositoryTest {
assertThat(result.isLeft()).isTrue()
verify(exactly = 0) { trackingContextProxy.eraseContext() }
}
@Test
fun `GIVEN stale backup status WHEN duplicate save rejected THEN stored card state refreshed`() = runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN locked wallet with stale backup status WHEN unlock with scanned card THEN stored card state refreshed`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse).let { wallet ->
wallet.copy(
scanResponse = wallet.scanResponse.copy(
card = wallet.scanResponse.card.copy(wallets = emptyList()),
),
)
}
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
coEvery { sensitiveInformationRepository.getAll(any()) } returns CompletionResult.Success(emptyMap())
// Act
val result = repository.unlock(
userWalletId = storedWallet.walletId,
unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(
scanResponse = freshScanResponse,
source = AnalyticsParam.ScreensSources.SignIn,
),
)
// Assert
assertThat(result.isRight()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN active card of backup set scanned WHEN duplicate save rejected THEN stored card state refreshed`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(staleScanResponse)
val otherCardScanResponse = freshScanResponse.copy(
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
)
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
assertThat(updatedWallet.scanResponse.card.backupStatus)
.isEqualTo(CardDTO.BackupStatus.Active(cardCount = 1))
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isTrue()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN no backup card of same wallet scanned WHEN duplicate save rejected THEN stored status downgraded`() =
runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
val newCardScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(cardId = "SAME-SEED-NEW-CARD"),
)
val freshWallet = MockUserWalletFactory.create(newCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
coEvery { publicInformationRepository.save(any(), any()) } returns CompletionResult.Success(Unit)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
val updatedWallet = repository.userWallets.value?.single() as UserWallet.Cold
assertThat(updatedWallet.scanResponse.card.cardId).isEqualTo(storedWallet.scanResponse.card.cardId)
assertThat(updatedWallet.scanResponse.card.backupStatus).isEqualTo(CardDTO.BackupStatus.NoBackup)
assertThat(updatedWallet.scanResponse.card.isAccessCodeSet).isFalse()
coVerify(exactly = 1) { publicInformationRepository.save(any(), true) }
}
@Test
fun `GIVEN stored card linked status WHEN duplicate save with another card rejected THEN status preserved`() =
runTest {
// Arrange
val cardLinkedScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(backupStatus = CardDTO.BackupStatus.CardLinked(cardCount = 1)),
)
val storedWallet = MockUserWalletFactory.create(cardLinkedScanResponse)
val otherCardScanResponse = freshScanResponse.copy(
card = freshScanResponse.card.copy(cardId = "OTHER-CARD"),
)
val freshWallet = MockUserWalletFactory.create(otherCardScanResponse)
repository.userWallets.value = listOf(storedWallet)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(repository.userWallets.value).containsExactly(storedWallet)
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
}
@Test
fun `GIVEN stored card state is actual WHEN duplicate save rejected THEN nothing persisted`() = runTest {
// Arrange
val storedWallet = MockUserWalletFactory.create(freshScanResponse)
val freshWallet = MockUserWalletFactory.create(freshScanResponse)
repository.userWallets.value = listOf(storedWallet)
// Act
val result = repository.saveWithoutLock(freshWallet, canOverride = false)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(repository.userWallets.value).containsExactly(storedWallet)
coVerify(exactly = 0) { publicInformationRepository.save(any(), any()) }
}
private companion object {
val staleScanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(maxWalletCount = 2),
derivedKeys = emptyMap(),
).let { scanResponse ->
scanResponse.copy(card = scanResponse.card.copy(backupStatus = CardDTO.BackupStatus.NoBackup))
}
val freshScanResponse = staleScanResponse.copy(
card = staleScanResponse.card.copy(
backupStatus = CardDTO.BackupStatus.Active(cardCount = 1),
isAccessCodeSet = true,
),
)
}
}