From 89fbfa5971ec7011b0e504e3a3967411fc5ed1d7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 Aug 2025 13:04:19 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../di/UserWalletsListManagerModule.kt | 130 +++++-- .../DefaultUserWalletsListRepository.kt | 357 ++++++++++++++++++ .../UserWalletEncryptionKeysRepository.kt | 185 +++++++++ domain/core/build.gradle.kts | 1 + .../core/wallets/UserWalletsListRepository.kt | 132 +++++++ .../core/wallets/error}/DeleteWalletError.kt | 2 +- .../core/wallets/error/LockWalletsError.kt | 6 + .../core/wallets/error}/SaveWalletError.kt | 2 +- .../core/wallets/error/SelectWalletError.kt | 6 + .../domain/core/wallets/error/SetLockError.kt | 10 + .../core/wallets/error/UnlockWalletError.kt | 14 + domain/models/build.gradle.kts | 1 - .../wallets/hot/HotWalletPasswordRequester.kt | 2 + .../wallets/usecase/DeleteWalletUseCase.kt | 2 +- .../wallets/usecase/SaveWalletUseCase.kt | 2 +- .../features/details/utils/UserWalletSaver.kt | 2 +- .../DefaultHotAccessCodeRequestComponent.kt | 5 + .../proxy/HotWalletPasswordRequesterProxy.kt | 4 + .../setaccesscode/AccessCodeModel.kt | 2 +- gradle/tangem_dependencies.toml | 2 +- 20 files changed, 828 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt create mode 100644 domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt rename domain/{wallets/src/main/java/com/tangem/domain/wallets/models => core/src/main/kotlin/com/tangem/domain/core/wallets/error}/DeleteWalletError.kt (66%) create mode 100644 domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/LockWalletsError.kt rename domain/{wallets/src/main/java/com/tangem/domain/wallets/models => core/src/main/kotlin/com/tangem/domain/core/wallets/error}/SaveWalletError.kt (84%) create mode 100644 domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SelectWalletError.kt create mode 100644 domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SetLockError.kt create mode 100644 domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/UnlockWalletError.kt 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 86be1e5223..f6323a20de 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 @@ -11,14 +11,18 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.domain.visa.model.VisaCardActivationStatus +import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.core.wallets.UserWalletsListRepository import com.tangem.sdk.storage.AndroidSecureStorage import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.createEncryptedSharedPreferences import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsListManager import com.tangem.tap.domain.userWalletList.implementation.GeneralUserWalletsListManager import com.tangem.tap.domain.userWalletList.implementation.RuntimeUserWalletsListManager +import com.tangem.tap.domain.userWalletList.repository.DefaultUserWalletsListRepository import com.tangem.tap.domain.userWalletList.repository.DelegatedKeystoreManager +import com.tangem.tap.domain.userWalletList.repository.UserWalletEncryptionKeysRepository import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysStoreDecorator import com.tangem.tap.domain.userWalletList.repository.implementation.BiometricUserWalletsKeysRepository import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultSelectedUserWalletRepository @@ -26,6 +30,7 @@ import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUse import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsSensitiveInformationRepository import com.tangem.tap.tangemSdkManager import com.tangem.utils.Provider +import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -40,6 +45,7 @@ internal object UserWalletsListManagerModule { @Provides @Singleton + @Deprecated("Use UserWalletsListRepository instead") fun provideGeneralUserWalletsListManager( @ApplicationContext applicationContext: Context, appPreferencesStore: AppPreferencesStore, @@ -58,42 +64,14 @@ internal object UserWalletsListManagerModule { ) } + @Deprecated("Use UserWalletsListRepository instead") private fun createBiometricUserWalletsListManager( applicationContext: Context, analyticsEventHandler: AnalyticsEventHandler, dispatchers: CoroutineDispatcherProvider, ): UserWalletsListManager { - val moshi = Moshi.Builder() - .add(WalletDerivedKeysMapAdapter()) - .add(ScanResponseDerivedKeysMapAdapter()) - .add(ByteArrayKeyAdapter()) - .add(ExtendedPublicKeysMapAdapter()) - .add(CardBackupStatusAdapter()) - .add(DerivationPathAdapterWithMigration()) - .add(TangemSdkAdapter.DateAdapter()) - .add(TangemSdkAdapter.DerivationNodeAdapter()) - .add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model - .add(VisaActivationRemoteState.jsonAdapter) - .add(VisaCardActivationStatus.jsonAdapter) - .addLast(KotlinJsonAdapterFactory()) - .build() - - val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "user_wallets_storage", - ), - androidSecureStorageV2 = AndroidSecureStorageV2( - appContext = applicationContext, - useStrongBox = true, - name = "user_wallets_storage2", - ), - androidSecureStorageV3 = AndroidSecureStorageV2( - appContext = applicationContext, - useStrongBox = false, - name = "user_wallets_storage3", - ), - ) + val moshi = buildMoshi() + val secureStorage = buildSecureStorage(applicationContext = applicationContext) val authenticatedStorage = AuthenticatedStorage( secureStorage = UserWalletsKeysStoreDecorator( @@ -134,4 +112,94 @@ internal object UserWalletsListManagerModule { selectedUserWalletRepository = selectedUserWalletRepository, ) } + + @Provides + @Singleton + fun provideUserWalletsListRepository( + @ApplicationContext applicationContext: Context, + dispatchers: CoroutineDispatcherProvider, + passwordRequester: HotWalletPasswordRequester, + ): UserWalletsListRepository { + val moshi = buildMoshi() + val secureStorage = buildSecureStorage(applicationContext = applicationContext) + + val authenticatedStorage = AuthenticatedStorage( + secureStorage = UserWalletsKeysStoreDecorator( + featureStorage = secureStorage, + cardSdkStorageProvider = Provider { tangemSdkManager.secureStorage }, + ), + keystoreManager = DelegatedKeystoreManager( + keystoreManagerProvider = Provider { tangemSdkManager.keystoreManager }, + ), + ) + + val publicInformationRepository = DefaultUserWalletsPublicInformationRepository( + moshi = moshi, + secureStorage = secureStorage, + ) + + val sensitiveInformationRepository = DefaultUserWalletsSensitiveInformationRepository( + moshi = moshi, + secureStorage = secureStorage, + ) + + val selectedUserWalletRepository = DefaultSelectedUserWalletRepository( + secureStorage = secureStorage, + dispatchers = dispatchers, + ) + + val userWalletEncryptionKeysRepository = UserWalletEncryptionKeysRepository( + moshi = moshi, + authenticatedStorage = authenticatedStorage, + dispatchers = dispatchers, + secureStorage = secureStorage, + ) + + return DefaultUserWalletsListRepository( + publicInformationRepository = publicInformationRepository, + sensitiveInformationRepository = sensitiveInformationRepository, + selectedUserWalletRepository = selectedUserWalletRepository, + passwordRequester = passwordRequester, + userWalletEncryptionKeysRepository = userWalletEncryptionKeysRepository, + tangemSdkManagerProvider = Provider { tangemSdkManager }, + savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now + // TODO add a settings toggle to disable saving persistent information + ) + } + + fun buildMoshi(): Moshi { + return Moshi.Builder() + .add(WalletDerivedKeysMapAdapter()) + .add(ScanResponseDerivedKeysMapAdapter()) + .add(ByteArrayKeyAdapter()) + .add(ExtendedPublicKeysMapAdapter()) + .add(CardBackupStatusAdapter()) + .add(DerivationPathAdapterWithMigration()) + .add(TangemSdkAdapter.DateAdapter()) + .add(TangemSdkAdapter.DerivationNodeAdapter()) + .add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model + .add(VisaActivationRemoteState.jsonAdapter) + .add(VisaCardActivationStatus.jsonAdapter) + .addLast(KotlinJsonAdapterFactory()) + .build() + } + + fun buildSecureStorage(@ApplicationContext applicationContext: Context): SecureStorage { + return AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "user_wallets_storage", + ), + androidSecureStorageV2 = AndroidSecureStorageV2( + appContext = applicationContext, + useStrongBox = true, + name = "user_wallets_storage2", + ), + androidSecureStorageV3 = AndroidSecureStorageV2( + appContext = applicationContext, + useStrongBox = false, + name = "user_wallets_storage3", + ), + ) + } } \ No newline at end of file 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 new file mode 100644 index 0000000000..d60a2a0f74 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -0,0 +1,357 @@ +package com.tangem.tap.domain.userWalletList.repository + +import arrow.core.Either +import arrow.core.left +import arrow.core.raise.either +import arrow.core.right +import com.tangem.common.doOnFailure +import com.tangem.common.doOnSuccess +import com.tangem.common.flatMap +import com.tangem.common.map +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.wallets.R +import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.domain.wallets.hot.HotWalletPasswordRequester +import com.tangem.domain.core.wallets.error.DeleteWalletError +import com.tangem.domain.core.wallets.error.LockWalletsError +import com.tangem.domain.core.wallets.error.SaveWalletError +import com.tangem.domain.core.wallets.error.SelectWalletError +import com.tangem.domain.core.wallets.error.SetLockError +import com.tangem.domain.core.wallets.error.UnlockWalletError +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.core.wallets.UserWalletsListRepository.LockMethod +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey +import com.tangem.tap.domain.userWalletList.utils.encryptionKey +import com.tangem.tap.domain.userWalletList.utils.lock +import com.tangem.tap.domain.userWalletList.utils.toUserWallets +import com.tangem.tap.domain.userWalletList.utils.updateWith +import com.tangem.utils.Provider +import com.tangem.utils.ProviderSuspend +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@Suppress("LongParameterList") +internal class DefaultUserWalletsListRepository( + private val publicInformationRepository: UserWalletsPublicInformationRepository, + private val sensitiveInformationRepository: UserWalletsSensitiveInformationRepository, + private val selectedUserWalletRepository: SelectedUserWalletRepository, + private val passwordRequester: HotWalletPasswordRequester, + private val userWalletEncryptionKeysRepository: UserWalletEncryptionKeysRepository, + private val tangemSdkManagerProvider: Provider, + private val savePersistentInformation: ProviderSuspend, +) : UserWalletsListRepository { + + override val userWallets = MutableStateFlow?>(null) + override val selectedUserWallet = MutableStateFlow(null) + + override suspend fun load() { + if (userWallets.value != null) return + + if (savePersistentInformation().not()) { + // If we don't save persistent information, we don't need to load user wallets + // and we should clear any existing data + clearPersistentData() + userWallets.value = emptyList() + return + } + + val unsecuredEncryptionKeys = userWalletEncryptionKeysRepository.getAllUnsecured() + + publicInformationRepository.getAll() + .map { it.toUserWallets() } + .flatMap { wallets -> + sensitiveInformationRepository.getAll(unsecuredEncryptionKeys) + .map { wallets.updateWith(it) } + }.doOnSuccess { + userWallets.value = it + } + + val selectedUserWalletId = selectedUserWalletRepository.get() + selectedUserWallet.value = userWallets.value?.firstOrNull { it.walletId == selectedUserWalletId } + ?: userWallets.value?.firstOrNull() + } + + override suspend fun userWalletsSync(): List { + load() + return userWallets.value!! + } + + override suspend fun selectedUserWalletSync(): UserWallet? { + load() + return selectedUserWallet.value + } + + override suspend fun select(userWalletId: UserWalletId): Either = either { + val userWallet = userWallets.value?.find { it.walletId == userWalletId } + ?: raise(SelectWalletError.UnableToSelectUserWallet) + selectedUserWalletRepository.set(userWalletId) + selectedUserWallet.value = userWallet + userWallet + } + + override suspend fun saveWithoutLock( + userWallet: UserWallet, + canOverride: Boolean, + ): Either = either { + if (canOverride.not() && userWallets.value?.any { it.walletId == userWallet.walletId } == true) { + raise(SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved)) + } + + if (savePersistentInformation()) { + publicInformationRepository.save(userWallet, canOverride) + if (userWallet.isLocked.not()) { + sensitiveInformationRepository.save(userWallet, userWallet.encryptionKey) + } + } + + // update the userWallets state and add if it doesn't exist + userWallets.update { currentWallets -> + val wallets = currentWallets ?: emptyList() + if (wallets.any { it.walletId == userWallet.walletId }) { + wallets.map { if (it.walletId == userWallet.walletId) userWallet else it } + } else { + wallets + userWallet + } + } + + // update the selectedUserWallet state if it is the only wallet + if (userWallets.value?.size == 1) { + selectedUserWalletRepository.set(userWallet.walletId) + selectedUserWallet.value = userWallet + } + + userWallet + } + + override suspend fun setLock(userWalletId: UserWalletId, lockMethod: LockMethod): Either = + either { + val userWallet = userWallets.value?.find { it.walletId == userWalletId } + ?: raise(SetLockError.UserWalletNotFound) + + val encryptionKey = userWallet.encryptionKey + ?: raise(SetLockError.UserWalletLocked) + + runCatching { + userWalletEncryptionKeysRepository.save( + encryptionKey = UserWalletEncryptionKey( + walletId = userWalletId, + encryptionKey = encryptionKey, + ), + method = when (lockMethod) { + is LockMethod.AccessCode -> { + UserWalletEncryptionKeysRepository.EncryptionMethod.Password(lockMethod.accessCode) + } + LockMethod.Biometric -> { + UserWalletEncryptionKeysRepository.EncryptionMethod.Biometric + } + LockMethod.NoLock -> { + if (userWallet is UserWallet.Cold) { + raise(SetLockError.UserWalletNotFound) + } + + UserWalletEncryptionKeysRepository.EncryptionMethod.Unsecured + } + }, + ) + }.onFailure { raise(SetLockError.UnableToSetLock(it)) } + } + + override suspend fun delete(userWalletIds: List): Either = either { + if (userWalletIds.isEmpty()) return Unit.right() + + publicInformationRepository.delete(userWalletIds) + .doOnFailure { + raise(DeleteWalletError.UnableToDelete) + } + sensitiveInformationRepository.delete(userWalletIds) + .doOnFailure { + raise(DeleteWalletError.UnableToDelete) + } + + userWalletEncryptionKeysRepository.delete(userWalletIds) + + userWallets.update { currentWallets -> + currentWallets?.filterNot { it.walletId in userWalletIds } + } + + selectedUserWallet.update { currentSelected -> + if (currentSelected == null) return@update null + + userWallets.value?.findAvailableUserWallet( + userWallets.value?.indexOfFirst { it.walletId == currentSelected.walletId } ?: 0, + ) + } + } + + override suspend fun unlock( + userWalletId: UserWalletId, + unlockMethod: UserWalletsListRepository.UnlockMethod, + ): Either = either { + val userWallet = userWallets.value?.find { it.walletId == userWalletId } + ?: raise(UnlockWalletError.UserWalletNotFound) + + if (userWallet.isLocked.not()) { + raise(UnlockWalletError.AlreadyUnlocked) + } + + when (unlockMethod) { + UserWalletsListRepository.UnlockMethod.Biometric -> { + unlockAllWallets() + select(userWalletId) + } + UserWalletsListRepository.UnlockMethod.AccessCode -> { + if (userWallet !is UserWallet.Hot) { + raise(UnlockWalletError.UnableToUnlock) + } + + val encryptionKey = requestPasswordRecursive( + block = { password -> + runCatching { + userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password) + }.onFailure { + raise(UnlockWalletError.UnableToUnlock) + }.getOrNull() + }, + biometryFallback = { + unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric) + }, + ).bind() + + if (encryptionKey == null) { + return@either + } + + sensitiveInformationRepository.getAll(listOf(encryptionKey)) + .doOnSuccess { userWallets.value?.updateWith(it) } + .doOnFailure { error -> + raise(UnlockWalletError.UnableToUnlock) + } + } + UserWalletsListRepository.UnlockMethod.Scan -> { + if (userWallet !is UserWallet.Cold) { + raise(UnlockWalletError.UnableToUnlock) + } + + tangemSdkManagerProvider().scanProduct() + .doOnSuccess { scanResponse -> + val expectedId = UserWalletIdBuilder.scanResponse(scanResponse).build() + + if (expectedId != userWallet.walletId) { + raise(UnlockWalletError.ScannedCardWalletNotMatched) + } + + saveWithoutLock(userWallet.copy(scanResponse = scanResponse), canOverride = true) + .mapLeft { UnlockWalletError.UnableToUnlock } + .bind() + } + .doOnFailure { + raise(UnlockWalletError.UserCancelled) + } + } + } + } + + override suspend fun unlockAllWallets(): Either = either { + val biometricKeys = runCatching { + userWalletEncryptionKeysRepository.getAllBiometric() + }.getOrElse { + // TODO handle error properly [REDACTED_TASK_KEY] + raise(UnlockWalletError.UserCancelled) + } + + val unsecuredKeys = userWalletEncryptionKeysRepository.getAllUnsecured() + val allKeys = biometricKeys + unsecuredKeys + sensitiveInformationRepository.getAll(allKeys) + .doOnSuccess { userWallets.value?.updateWith(it) } + } + + override suspend fun lockAllWallets(): Either = either { + val unsecuredWalletIds = userWalletEncryptionKeysRepository.getAllUnsecured().map { it.walletId }.toSet() + + if (unsecuredWalletIds.size == userWallets.value?.size) { + raise(LockWalletsError.NothingToLock) + } + + userWallets.update { + it?.map { + if (it.walletId !in unsecuredWalletIds) { + it.lock() + } else { + it + } + } + } + } + + override suspend fun clearPersistentData() { + publicInformationRepository.clear() + sensitiveInformationRepository.clear() + userWalletEncryptionKeysRepository.clear() + } + + private suspend fun requestPasswordRecursive( + block: suspend (CharArray) -> UserWalletEncryptionKey?, + biometryFallback: suspend () -> Either, + ): Either { + val result = passwordRequester.requestPassword( + hasBiometry = tangemSdkManagerProvider.invoke().needEnrollBiometrics, + ) + + return when (result) { + HotWalletPasswordRequester.Result.Dismiss -> { + passwordRequester.dismiss() + UnlockWalletError.UserCancelled.left() + } + is HotWalletPasswordRequester.Result.EnteredPassword -> { + val decrypted = block(result.password.value) + if (decrypted == null) { + passwordRequester.wrongPassword() + requestPasswordRecursive(block, biometryFallback) + } else { + passwordRequester.successfulAuthentication() + decrypted.right() + } + } + HotWalletPasswordRequester.Result.UseBiometry -> { + biometryFallback() + .onRight { + passwordRequester.successfulAuthentication() + } + passwordRequester.dismiss() + null.right() + } + } + } + + /** + * Find the nearest available wallet that can be selected + * + * Example: + * Number with *n* is previous selected wallet with index [prevSelectedIndex]. + * + * 1. [*1*, 2, 3, 4] => delete 1 => [2, 3, 4] => find and select => [*2*, 3, 4] + * 2. [1, *2*, 3, 4] => delete 2 => [1, 3, 4] => find and select => [1, *3*, 4] + * 3. [1, 2, *3*, 4] => delete 3 => [1, 2, 4] => find and select => [1, 2, *4*] + * 4. [1, 2, 3, *4*] => delete 4 => [1, 2, 3] => find and select => [1, 2, *3*] + * + * @receiver list of user wallets without deleted wallet + */ + private fun List.findAvailableUserWallet(prevSelectedIndex: Int): UserWallet? { + if (prevSelectedIndex == 0) return firstOrNull { !it.isLocked } ?: firstOrNull() + + if (prevSelectedIndex in indices && !this[prevSelectedIndex].isLocked) return this[prevSelectedIndex] + + for (offset in 1..size) { + val rightIndex = prevSelectedIndex + offset + if (rightIndex in indices && !this[rightIndex].isLocked) return this[rightIndex] + + val leftIndex = prevSelectedIndex - offset + if (leftIndex in indices && !this[leftIndex].isLocked) return this[leftIndex] + } + + return lastOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt new file mode 100644 index 0000000000..fb42970c29 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/UserWalletEncryptionKeysRepository.kt @@ -0,0 +1,185 @@ +package com.tangem.tap.domain.userWalletList.repository + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.tangem.common.authentication.storage.AuthenticatedStorage +import com.tangem.common.services.secure.SecureStorage +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.hot.sdk.android.crypto.AESEncryptionProtocol +import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +internal class UserWalletEncryptionKeysRepository( + moshi: Moshi, + private val authenticatedStorage: AuthenticatedStorage, + private val dispatchers: CoroutineDispatcherProvider, + private val secureStorage: SecureStorage, +) { + + private val encryptionKeyAdapter: JsonAdapter = moshi.adapter( + UserWalletEncryptionKey::class.java, + ) + private val userWalletsIdsListAdapter: JsonAdapter> = moshi.adapter( + Types.newParameterizedType(List::class.java, UserWalletId::class.java), + ) + + suspend fun save(encryptionKey: UserWalletEncryptionKey, method: EncryptionMethod) = withContext(dispatchers.io) { + secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(encryptionKey.walletId).name) + + when (method) { + EncryptionMethod.Unsecured -> { + secureStorage.store( + account = StorageKey.UserWalletEncryptionKeyUnsecured(encryptionKey.walletId).name, + data = encryptionKey.encode(), + ) + } + EncryptionMethod.Biometric -> { + authenticatedStorage.store( + keyAlias = StorageKey.UserWalletEncryptionKey(encryptionKey.walletId).name, + data = encryptionKey.encode(), + ) + } + is EncryptionMethod.Password -> { + val encodedWithPass = AESEncryptionProtocol.encryptWithPassword( + password = method.password, + content = encryptionKey.encode(), + ) + secureStorage.store( + account = StorageKey.UserWalletEncryptionKeyEncrypted(encryptionKey.walletId).name, + data = encodedWithPass, + ) + } + } + + storeUserWalletId(userWalletId = encryptionKey.walletId) + } + + suspend fun getAllUnsecured(): List = withContext(dispatchers.io) { + getUserWalletsIds().mapNotNull { userWalletId -> + secureStorage.get(account = StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name).decodeToKey() + } + } + + suspend fun getEncryptedWithPassword(userWalletId: UserWalletId, password: CharArray): UserWalletEncryptionKey? { + val encrypted = secureStorage.get( + account = StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name, + ) ?: return null + + val decrypted = AESEncryptionProtocol.decryptWithPassword(password, encrypted) + + return decrypted.decodeToKey() + } + + suspend fun getAllBiometric(): List = withContext(dispatchers.io) { + val keys = getUserWalletsIds().map { userWalletId -> + StorageKey.UserWalletEncryptionKey(userWalletId).name + } + + authenticatedStorage.get(keys).mapNotNull { + it.value.decodeToKey() + } + } + + suspend fun delete(userWalletIds: List) { + if (userWalletIds.isEmpty()) return + + withContext(dispatchers.io) { + userWalletIds.forEach { userWalletId -> + secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name) + secureStorage.delete(StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name) + authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name) + } + + val userWalletsIds = getUserWalletsIds().filterNot { it in userWalletIds } + secureStorage.store(userWalletsIds.encode(), StorageKey.UserWalletIds.name) + } + } + + suspend fun clear() { + withContext(dispatchers.io) { + val userWalletsIds = getUserWalletsIds() + userWalletsIds.forEach { userWalletId -> + secureStorage.delete(StorageKey.UserWalletEncryptionKeyUnsecured(userWalletId).name) + secureStorage.delete(StorageKey.UserWalletEncryptionKeyEncrypted(userWalletId).name) + authenticatedStorage.delete(StorageKey.UserWalletEncryptionKey(userWalletId).name) + } + secureStorage.delete(StorageKey.UserWalletIds.name) + } + } + + private suspend fun getUserWalletsIds(): List { + return withContext(dispatchers.io) { + secureStorage.get(StorageKey.UserWalletIds.name) + .decodeToUserWalletsIds() + } + } + + private suspend fun storeUserWalletId(userWalletId: UserWalletId) { + val userWalletIds = (getUserWalletsIds() + userWalletId).distinct() + + withContext(dispatchers.io) { + secureStorage.store(userWalletIds.encode(), StorageKey.UserWalletIds.name) + } + } + + private suspend fun UserWalletEncryptionKey.encode(): ByteArray { + return withContext(dispatchers.default) { + this@encode + .let(encryptionKeyAdapter::toJson) + .encodeToByteArray(throwOnInvalidSequence = true) + } + } + + private suspend fun ByteArray?.decodeToKey(): UserWalletEncryptionKey? { + return withContext(dispatchers.default) { + this@decodeToKey + ?.decodeToString(throwOnInvalidSequence = true) + ?.let(encryptionKeyAdapter::fromJson) + } + } + + private suspend fun List.encode(): ByteArray { + return withContext(dispatchers.default) { + this@encode + .let(userWalletsIdsListAdapter::toJson) + .encodeToByteArray(throwOnInvalidSequence = true) + } + } + + private suspend fun ByteArray?.decodeToUserWalletsIds(): List { + return withContext(dispatchers.default) { + this@decodeToUserWalletsIds + ?.decodeToString(throwOnInvalidSequence = true) + ?.let(userWalletsIdsListAdapter::fromJson) + .orEmpty() + } + } + + sealed class EncryptionMethod { + data object Unsecured : EncryptionMethod() + data object Biometric : EncryptionMethod() + class Password(val password: CharArray) : EncryptionMethod() + } + + private sealed interface StorageKey { + val name: String + + class UserWalletEncryptionKeyUnsecured(userWalletId: UserWalletId) : StorageKey { + override val name: String = "user_wallet_encryption_key_unsecured_${userWalletId.stringValue}" + } + + class UserWalletEncryptionKey(userWalletId: UserWalletId) : StorageKey { + override val name: String = "user_wallet_encryption_key_${userWalletId.stringValue}" + } + + class UserWalletEncryptionKeyEncrypted(userWalletId: UserWalletId) : StorageKey { + override val name: String = "user_wallet_encryption_key_encrypted_${userWalletId.stringValue}" + } + + object UserWalletIds : StorageKey { + override val name: String = "user_wallets_ids_with_saved_keys" + } + } +} \ No newline at end of file diff --git a/domain/core/build.gradle.kts b/domain/core/build.gradle.kts index 9910d828a3..20e637a0a5 100644 --- a/domain/core/build.gradle.kts +++ b/domain/core/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { api(deps.kotlin.coroutines) api(deps.arrow.core) api(deps.arrow.fx) + api(projects.domain.models) implementation(deps.kotlin.serialization) diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt new file mode 100644 index 0000000000..f0f9ee988a --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/UserWalletsListRepository.kt @@ -0,0 +1,132 @@ +package com.tangem.domain.core.wallets + +import arrow.core.Either +import com.tangem.domain.core.wallets.error.DeleteWalletError +import com.tangem.domain.core.wallets.error.LockWalletsError +import com.tangem.domain.core.wallets.error.SaveWalletError +import com.tangem.domain.core.wallets.error.SelectWalletError +import com.tangem.domain.core.wallets.error.SetLockError +import com.tangem.domain.core.wallets.error.UnlockWalletError +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.StateFlow + +/** + * Repository for managing user wallets list. + * It provides methods to load, select, save, lock, unlock, and delete user wallets. + * + * TODO tests [REDACTED_TASK_KEY] + * + * @see com.tangem.domain.models.wallet.UserWallet + * @see com.tangem.domain.models.wallet.UserWalletId + */ +interface UserWalletsListRepository { + + /** + * List of user wallets. + * It can be null if the list is not loaded yet. + */ + val userWallets: StateFlow?> + + /** + * Currently selected user wallet. + * It can be null if wallets list is not loaded yet or wallets list is empty. + */ + val selectedUserWallet: StateFlow + + /** + * Loads user wallets list and selected wallet. + * If the list is already loaded, it does nothing. + */ + suspend fun load() + + /** + * Gets and if necessary loads user wallets list and selected wallet. + */ + suspend fun userWalletsSync(): List + + /** + * Gets and if necessary loads selected user wallet. + */ + suspend fun selectedUserWalletSync(): UserWallet? + + /** + * Selects user wallet by id. + * If the wallet is not found, it returns [SelectWalletError.UnableToSelectUserWallet]. + */ + suspend fun select(userWalletId: UserWalletId): Either + + /** + * Saves user wallet. + * If the wallet already exists and [canOverride] is false, it returns [SaveWalletError.WalletAlreadySaved]. + * If the wallet already exists and [canOverride] is true, it overrides the existing wallet. + * + * Does not lock the wallet after saving, it should be done manually using [setLock] method. + */ + suspend fun saveWithoutLock( + userWallet: UserWallet, + canOverride: Boolean = true, + ): Either + + /** + * Sets lock for **unlocked** user wallet. + * If the wallet is not found, it returns [SetLockError.UserWalletNotFound] + * If the wallet is locked, it returns [SetLockError.UserWalletLocked] + * If the lock method is not supported, it returns [SetLockError.UnableToSetLock]. + */ + suspend fun setLock(userWalletId: UserWalletId, lockMethod: LockMethod): Either + + /** + * Deletes user wallets by ids. + * If the wallet is not found, it returns [DeleteWalletError.UnableToDelete] + */ + suspend fun delete(userWalletIds: List): Either + + /** + * Unlocks specific user wallet. + * If the wallet is already unlocked, returns [UnlockWalletError.AlreadyUnlocked]. + * If the wallet is not found, returns [UnlockWalletError.UserWalletNotFound]. + * If the unlock method is not supported, returns [UnlockWalletError.UnableToUnlock] + * If the user cancels the unlock operation (ex. dismisses dialogs), returns [UnlockWalletError.UserCancelled]. + * If the scanned card does not match the wallet, returns [UnlockWalletError.ScannedCardWalletNotMatched]. + */ + suspend fun unlock(userWalletId: UserWalletId, unlockMethod: UnlockMethod): Either + + /** + * Unlocks all user wallets using biometric authentication. + * If all the wallets was are already unlocked, returns [UnlockWalletError.AlreadyUnlocked]. + * Success if at least one wallet was unlocked. + * If the biometric method is not supported for some of user wallets, returns [UnlockWalletError.UnableToUnlock] + */ + suspend fun unlockAllWallets(): Either + + /** + * Locks all secured user wallets (wallets that are not locked with [LockMethod.NoLock]). + * If all the wallets are already locked or unsecured, returns [LockWalletsError.NothingToLock]. + * Success if at least one wallet was locked. + */ + suspend fun lockAllWallets(): Either + + /** + * Clears all persistent data related to user wallets. + * This includes removing all user wallets, selected wallet, and any other related data. + * User wallets will stay in the cache, but will be reloaded on next repository initialization. + */ + suspend fun clearPersistentData() + + sealed class LockMethod { + data object Biometric : LockMethod() + class AccessCode(val accessCode: CharArray) : LockMethod() + data object NoLock : LockMethod() + } + + enum class UnlockMethod { + Biometric, + AccessCode, + Scan, + } +} + +fun UserWalletsListRepository.requireUserWalletsSync(): List { + return userWallets.value ?: error("User wallets list is not loaded") +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/DeleteWalletError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/DeleteWalletError.kt similarity index 66% rename from domain/wallets/src/main/java/com/tangem/domain/wallets/models/DeleteWalletError.kt rename to domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/DeleteWalletError.kt index d58c220ac3..91b685a84c 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/DeleteWalletError.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/DeleteWalletError.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.wallets.models +package com.tangem.domain.core.wallets.error sealed interface DeleteWalletError { diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/LockWalletsError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/LockWalletsError.kt new file mode 100644 index 0000000000..abedc5aa4a --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/LockWalletsError.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.core.wallets.error + +interface LockWalletsError { + + data object NothingToLock : LockWalletsError +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/SaveWalletError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SaveWalletError.kt similarity index 84% rename from domain/wallets/src/main/java/com/tangem/domain/wallets/models/SaveWalletError.kt rename to domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SaveWalletError.kt index a41524b8b7..30fc8a743b 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/SaveWalletError.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SaveWalletError.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.wallets.models +package com.tangem.domain.core.wallets.error /** [REDACTED_AUTHOR] diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SelectWalletError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SelectWalletError.kt new file mode 100644 index 0000000000..05b07e53d0 --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SelectWalletError.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.core.wallets.error + +sealed interface SelectWalletError { + + data object UnableToSelectUserWallet : SelectWalletError +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SetLockError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SetLockError.kt new file mode 100644 index 0000000000..688f618c74 --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/SetLockError.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.core.wallets.error + +sealed interface SetLockError { + + data object UserWalletNotFound : SetLockError + + data object UserWalletLocked : SetLockError + + data class UnableToSetLock(val cause: Throwable) : SetLockError +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/UnlockWalletError.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/UnlockWalletError.kt new file mode 100644 index 0000000000..8bed3120a7 --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/wallets/error/UnlockWalletError.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.core.wallets.error + +sealed interface UnlockWalletError { + + data object AlreadyUnlocked : UnlockWalletError + + data object UserWalletNotFound : UnlockWalletError + + data object UnableToUnlock : UnlockWalletError + + data object UserCancelled : UnlockWalletError + + data object ScannedCardWalletNotMatched : UnlockWalletError +} \ No newline at end of file diff --git a/domain/models/build.gradle.kts b/domain/models/build.gradle.kts index 9bd4e4940a..6c9f6b1143 100644 --- a/domain/models/build.gradle.kts +++ b/domain/models/build.gradle.kts @@ -12,7 +12,6 @@ tasks.withType().configureEach { } dependencies { - api(projects.domain.core) api(projects.domain.visa.models) api(projects.core.utils) diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt index 6f8391e887..7dcc5fa579 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletPasswordRequester.kt @@ -6,6 +6,8 @@ interface HotWalletPasswordRequester { suspend fun wrongPassword() + suspend fun successfulAuthentication() + suspend fun requestPassword(hasBiometry: Boolean): Result suspend fun dismiss() diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt index 65faf81196..c876b7d526 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.raise.either import com.tangem.common.doOnFailure import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.wallets.models.DeleteWalletError +import com.tangem.domain.core.wallets.error.DeleteWalletError import com.tangem.domain.models.wallet.UserWalletId /** diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SaveWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SaveWalletUseCase.kt index 66777ba22b..9ff8c5bb81 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SaveWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SaveWalletUseCase.kt @@ -8,7 +8,7 @@ import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.domain.wallets.legacy.UserWalletsListError import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.domain.wallets.models.SaveWalletError +import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.wallet.UserWallet /** diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index 10c6667755..7915a2a320 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -21,7 +21,7 @@ import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.models.SaveWalletError +import com.tangem.domain.core.wallets.error.SaveWalletError import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/DefaultHotAccessCodeRequestComponent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/DefaultHotAccessCodeRequestComponent.kt index f23af23895..5bacfc9fef 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/DefaultHotAccessCodeRequestComponent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/DefaultHotAccessCodeRequestComponent.kt @@ -25,6 +25,11 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor( model.wrongAccessCode() } + override suspend fun successfulAuthentication() { + // TODO handle successful authentication + // TODO add delay + } + override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result { model.show(hasBiometry) return model.waitResult() diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/proxy/HotWalletPasswordRequesterProxy.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/proxy/HotWalletPasswordRequesterProxy.kt index 5208d91418..b7e7218a48 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/proxy/HotWalletPasswordRequesterProxy.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/proxy/HotWalletPasswordRequesterProxy.kt @@ -17,6 +17,10 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR call { wrongPassword() } } + override suspend fun successfulAuthentication() { + call { successfulAuthentication() } + } + override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result = call { requestPassword(hasBiometry) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setaccesscode/AccessCodeModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setaccesscode/AccessCodeModel.kt index 75210c3f64..e387f3e404 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setaccesscode/AccessCodeModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/setaccesscode/AccessCodeModel.kt @@ -83,7 +83,7 @@ internal class AccessCodeModel @Inject constructor( unlockHotWallet = unlockHotWallet, auth = HotAuth.Password(accessCode.toCharArray()), ) - saveWalletUseCase(userWallet.copy(hotWalletId = updatedHotWalletId)) + saveWalletUseCase(userWallet.copy(hotWalletId = updatedHotWalletId), canOverride = true) params.callbacks.onAccessCodeConfirmed(params.userWalletId) } }.onFailure { diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e880536eea..5f56886fc4 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -11,7 +11,7 @@ tangemCardSdk = "develop-509" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-446" +tangemHotSdk = "develop-448" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^