diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index e2a1e3fd27..f514d4b3e7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -22,6 +22,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 @@ -127,6 +129,8 @@ internal class DefaultUserWalletsListRepository( canOverride: Boolean, ): Either = 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)) } @@ -322,6 +326,8 @@ internal class DefaultUserWalletsListRepository( raise(UnlockWalletError.ScannedCardWalletNotMatched) } + refreshStoredCardState(scanResponse) + val encryptionKey = UserWalletEncryptionKey( walletId = userWallet.walletId, encryptionKey = scanResponse.encryptionKey ?: raise(UnlockWalletError.UnableToUnlock.Empty), @@ -449,6 +455,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, diff --git a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt index 6593737113..ae527a4ca6 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepositoryTest.kt @@ -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, + ), + ) + } } \ No newline at end of file