From 9b506f8b255227b278afd32dbc287c82d28f0a94 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 20 Nov 2025 17:20:38 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../di/UserWalletsListManagerModule.kt | 3 ++ .../DefaultUserWalletsListRepository.kt | 48 ++++++++++++++++--- .../tangem/domain/models/wallet/UserWallet.kt | 10 ++++ .../NonBiometricUnlockWalletUseCase.kt | 8 ++++ .../wallets/usecase/UnlockWalletUseCase.kt | 17 ++++++- .../model/MultiWalletFinalizeModel.kt | 40 +++++++++++----- 6 files changed, 107 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt index 5ea3c3c88b..e0b08af719 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt @@ -15,6 +15,7 @@ import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.hot.sdk.TangemHotSdk import com.tangem.sdk.storage.AndroidSecureStorage import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.createEncryptedSharedPreferences @@ -122,6 +123,7 @@ internal object UserWalletsListManagerModule { passwordRequester: HotWalletPasswordRequester, appPreferencesStore: AppPreferencesStore, hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + tangemHotSdk: TangemHotSdk, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -168,6 +170,7 @@ internal object UserWalletsListManagerModule { appPreferencesStore = appPreferencesStore, savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now hotWalletAccessCodeAttemptsRepository = hotWalletAccessCodeAttemptsRepository, + tangemHotSdk = tangemHotSdk, ) } 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 4f38f8e6ce..8a02a4f83a 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 @@ -18,6 +18,7 @@ import com.tangem.domain.wallets.R import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester +import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotWalletId import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey @@ -28,6 +29,7 @@ import com.tangem.tap.domain.userWalletList.utils.updateWith import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -45,6 +47,7 @@ internal class DefaultUserWalletsListRepository( private val savePersistentInformation: ProviderSuspend, private val appPreferencesStore: AppPreferencesStore, private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + private val tangemHotSdk: TangemHotSdk, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -120,14 +123,19 @@ internal class DefaultUserWalletsListRepository( } } + val oldUserWallet = userWalletsSync().find { it.walletId == userWallet.walletId } + + if (oldUserWallet != null) { + checkForUpgradeAndDeleteHotWalletIfNeeded( + newUserWallet = userWallet, + oldUserWallet = oldUserWallet, + ) + } + // update the userWallets state and add if it doesn't exist updateWallets { currentWallets -> val wallets = currentWallets.orEmpty() - if (wallets.any { it.walletId == userWallet.walletId }) { - wallets.map { if (it.walletId == userWallet.walletId) userWallet else it } - } else { - wallets + userWallet - } + wallets.addOrReplace(userWallet) { it.walletId == userWallet.walletId } } // update the selectedUserWallet state if it is the only wallet @@ -194,6 +202,8 @@ internal class DefaultUserWalletsListRepository( userWalletEncryptionKeysRepository.delete(userWalletIds) + removeHotWalletsFromSDK(userWalletIds) + userWallets.update { currentWallets -> val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } selectedUserWallet.update { currentSelected -> @@ -288,7 +298,12 @@ internal class DefaultUserWalletsListRepository( } override suspend fun unlockAllWallets(): Either = either { - val userWallets = userWalletsSync() + val userWallets = userWalletsSync().filter { it.isLocked } + + if (userWallets.isEmpty()) { + return@either + } + val biometricKeys = runSuspendCatching { userWalletEncryptionKeysRepository.getAllBiometric() }.getOrElse { @@ -352,6 +367,27 @@ internal class DefaultUserWalletsListRepository( return userWallets.any { it.walletId !in unsecuredWalletIds } } + private suspend fun checkForUpgradeAndDeleteHotWalletIfNeeded( + newUserWallet: UserWallet, + oldUserWallet: UserWallet, + ) { + if (newUserWallet.walletId == oldUserWallet.walletId && + oldUserWallet is UserWallet.Hot && newUserWallet is UserWallet.Cold + ) { + removeHotWalletsFromSDK(walletIds = listOf(oldUserWallet.walletId)) + } + } + + private suspend fun removeHotWalletsFromSDK(walletIds: List) { + val hotWalletsToDelete = userWalletsSync() + .filterIsInstance() + .filter { walletIds.contains(it.walletId) } + + hotWalletsToDelete.forEach { + tangemHotSdk.delete(it.hotWalletId) + } + } + private suspend fun requestPasswordRecursive( hotWalletId: HotWalletId, block: suspend (CharArray) -> UserWalletEncryptionKey?, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index a196930c5b..30b9500769 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -76,6 +76,16 @@ fun UserWallet.requireColdWallet(): UserWallet.Cold { ?: error("This user wallet is not a cold wallet") } +@OptIn(ExperimentalContracts::class) +fun UserWallet.requireHotWallet(): UserWallet.Hot { + contract { + returns() implies (this@requireHotWallet is UserWallet.Hot) + } + + return this as? UserWallet.Hot + ?: error("This user wallet is not a hot wallet") +} + fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) { is UserWallet.Cold -> this.copy(name = name, walletId = walletId) is UserWallet.Hot -> this.copy(name = name, walletId = walletId) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt index c3b777d3af..d225546c2d 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt @@ -9,6 +9,14 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.wallets.repository.WalletsRepository +/** + * Use case for unlocking a wallet using non-biometric methods. + * If the wallet is already unlocked, it does nothing. + * + * **Does not** return [UnlockWalletError.AlreadyUnlocked] as an error, since the wallet is already unlocked. + * + * @see UnlockWalletUseCase + */ class NonBiometricUnlockWalletUseCase( private val userWalletsListRepository: UserWalletsListRepository, private val walletsRepository: WalletsRepository, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt index 9d24f06f3c..61d4c4e10c 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt @@ -6,13 +6,26 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.UnlockWalletError import com.tangem.domain.models.wallet.UserWalletId +/** + * Use case for unlocking a wallet using biometric authentication. + * If biometric unlock fails, it falls back to non-biometric unlock methods. + * + * ** Does not** return [UnlockWalletError.AlreadyUnlocked] as an error, since the wallet is already unlocked. + * + * @see NonBiometricUnlockWalletUseCase + */ class UnlockWalletUseCase( private val userWalletsListRepository: UserWalletsListRepository, private val nonBiometricUnlockWalletUseCase: NonBiometricUnlockWalletUseCase, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either = either { - userWalletsListRepository.unlockAllWallets() - .mapLeft { nonBiometricUnlockWalletUseCase(userWalletId).bind() } + userWalletsListRepository.unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric) + .mapLeft { error -> + when (error) { + UnlockWalletError.AlreadyUnlocked -> Unit + else -> nonBiometricUnlockWalletUseCase(userWalletId).bind() + } + } } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index e2d6ccafca..3fa335c815 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -1,8 +1,10 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model import androidx.compose.runtime.Stable +import arrow.core.getOrElse import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.ByteArrayKey import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -17,6 +19,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.isRing import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.requireColdWallet +import com.tangem.domain.models.wallet.requireHotWallet import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository @@ -33,6 +36,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDia import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage.* import com.tangem.features.onboarding.v2.util.ResetCardsComponent import com.tangem.operations.backup.BackupService +import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.sdk.api.BackupServiceHolder import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.StringsSigns @@ -45,7 +49,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class MultiWalletFinalizeModel @Inject constructor( @@ -255,7 +259,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, -> { - saveWalletUseCase( + saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( scanResponse = scanResponse.updateScanResponseAfterBackup(), ), @@ -271,7 +275,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( } ?: userWalletCreated - updateWalletUseCase( + updateWalletUseCase.invoke( userWalletId = userWallet.walletId, update = { it.requireColdWallet().copy( @@ -283,14 +287,20 @@ internal class MultiWalletFinalizeModel @Inject constructor( userWallet } is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { - saveWalletUseCase( - userWallet = userWalletCreated.copy( - scanResponse = scanResponse.updateScanResponseAfterBackup(), - ), - canOverride = true, - ) - // TODO [REDACTED_TASK_KEY] remove hot wallet after upgrade - userWalletCreated + updateWalletUseCase.invoke( + userWalletId = userWalletCreated.walletId, + update = { hotUserWallet -> + val wallet = hotUserWallet.requireHotWallet() + userWalletCreated.copy( + name = wallet.name, + scanResponse = scanResponse + .updateScanResponseAfterBackup() + .updateWithHotWallet(wallet), + ) + }, + ).getOrElse { + error("Failed to upgrade to cold wallet. Error: $it") + } } }.requireColdWallet() @@ -350,6 +360,14 @@ internal class MultiWalletFinalizeModel @Inject constructor( return copy(card = card) } + private fun ScanResponse.updateWithHotWallet(hotWallet: UserWallet.Hot): ScanResponse { + return copy( + derivedKeys = derivedKeys + hotWallet.wallets?.associate { + ByteArrayKey(it.publicKey) to ExtendedPublicKeysMap(it.derivedKeys) + }.orEmpty(), + ) + } + private fun handleActivationError() { _uiState.update { st -> st.copy(