Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-20 17:20:38 +03:00
parent 02e44033c9
commit 9b506f8b25
6 changed files with 107 additions and 19 deletions

View file

@ -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,
)
}

View file

@ -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<Boolean>,
private val appPreferencesStore: AppPreferencesStore,
private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val tangemHotSdk: TangemHotSdk,
) : UserWalletsListRepository {
override val userWallets = MutableStateFlow<List<UserWallet>?>(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<UnlockWalletError, Unit> = 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<UserWalletId>) {
val hotWalletsToDelete = userWalletsSync()
.filterIsInstance<UserWallet.Hot>()
.filter { walletIds.contains(it.walletId) }
hotWalletsToDelete.forEach {
tangemHotSdk.delete(it.hotWalletId)
}
}
private suspend fun requestPasswordRecursive(
hotWalletId: HotWalletId,
block: suspend (CharArray) -> UserWalletEncryptionKey?,

View file

@ -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)

View file

@ -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,

View file

@ -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<UnlockWalletError, Unit> = either {
userWalletsListRepository.unlockAllWallets()
.mapLeft { nonBiometricUnlockWalletUseCase(userWalletId).bind() }
userWalletsListRepository.unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric)
.mapLeft { error ->
when (error) {
UnlockWalletError.AlreadyUnlocked -> Unit
else -> nonBiometricUnlockWalletUseCase(userWalletId).bind()
}
}
}
}

View file

@ -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(