From 1563b48e8a76c4fb20700a16694fd217268505f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 Aug 2025 14:11:12 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../di/UserWalletsListManagerModule.kt | 3 + .../DefaultUserWalletsListRepository.kt | 39 ++++- .../local/preferences/PreferencesKeys.kt | 12 ++ .../data/wallets/di/WalletsDataModule.kt | 8 + ...ltHotWalletAccessCodeAttemptsRepository.kt | 138 ++++++++++++++++++ .../data/wallets/hot/HotWalletAccessor.kt | 35 ++++- .../HotWalletAccessCodeAttemptsRepository.kt | 71 +++++++++ .../wallets/hot/HotWalletPasswordRequester.kt | 34 ++++- .../DefaultHotAccessCodeRequestComponent.kt | 6 +- .../HotAccessCodeRequestModel.kt | 110 +++++++++++++- .../entity/HotAccessCodeRequestUM.kt | 2 + .../proxy/HotWalletPasswordRequesterProxy.kt | 17 +-- .../HotAccessCodeRequestFullScreenContent.kt | 36 ++++- .../welcome/impl/model/WelcomeModel.kt | 8 +- 14 files changed, 485 insertions(+), 34 deletions(-) create mode 100644 data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessCodeAttemptsRepository.kt create mode 100644 domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessCodeAttemptsRepository.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 d8173d8bc7..c521709443 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 @@ -14,6 +14,7 @@ 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.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.sdk.storage.AndroidSecureStorage import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.createEncryptedSharedPreferences @@ -120,6 +121,7 @@ internal object UserWalletsListManagerModule { dispatchers: CoroutineDispatcherProvider, passwordRequester: HotWalletPasswordRequester, appPreferencesStore: AppPreferencesStore, + hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -165,6 +167,7 @@ internal object UserWalletsListManagerModule { tangemSdkManagerProvider = Provider { tangemSdkManager }, appPreferencesStore = appPreferencesStore, savePersistentInformation = ProviderSuspend { true }, // Always save persistent information for now + hotWalletAccessCodeAttemptsRepository = hotWalletAccessCodeAttemptsRepository, ) } 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 d900b3f687..93810c48da 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 @@ -25,6 +25,8 @@ 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.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.userWalletList.model.UserWalletEncryptionKey import com.tangem.tap.domain.userWalletList.utils.encryptionKey @@ -37,7 +39,7 @@ import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DefaultUserWalletsListRepository( private val publicInformationRepository: UserWalletsPublicInformationRepository, private val sensitiveInformationRepository: UserWalletsSensitiveInformationRepository, @@ -47,6 +49,7 @@ internal class DefaultUserWalletsListRepository( private val tangemSdkManagerProvider: Provider, private val savePersistentInformation: ProviderSuspend, private val appPreferencesStore: AppPreferencesStore, + private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -225,6 +228,7 @@ internal class DefaultUserWalletsListRepository( } val encryptionKey = requestPasswordRecursive( + hotWalletId = userWallet.hotWalletId, block = { password -> runCatching { userWalletEncryptionKeysRepository.getEncryptedWithPassword(userWalletId, password) @@ -241,6 +245,8 @@ internal class DefaultUserWalletsListRepository( return@either } + removePasswordAttempts(userWallet) + sensitiveInformationRepository.getAll(listOf(encryptionKey)) .doOnSuccess { sensitiveInfo -> userWallets.update { it?.updateWith(sensitiveInfo) } } .doOnFailure { error -> @@ -282,15 +288,26 @@ internal class DefaultUserWalletsListRepository( val unsecuredKeys = userWalletEncryptionKeysRepository.getAllUnsecured() val allKeys = (biometricKeys + unsecuredKeys).distinct() - val unlockedWallets = allKeys.map { it.walletId } + val unlockedWalletsIds = allKeys.map { it.walletId } + + val unlockedWallets = unlockedWalletsIds.mapNotNull { id -> + userWalletsSync().firstOrNull { it.walletId == id } + } + + // Remove all password attempts for unlocked hot wallets + unlockedWallets.forEach { + removePasswordAttempts(it) + } // if we cant unlock all wallets - if (userWalletIds.all { it in unlockedWallets }.not()) { + if (userWalletIds.all { it in unlockedWalletsIds }.not()) { raise(UnlockWalletError.UnableToUnlock) } sensitiveInformationRepository.getAll(allKeys) - .doOnSuccess { sensitiveInfo -> userWallets.update { it?.updateWith(sensitiveInfo) } } + .doOnSuccess { sensitiveInfo -> + userWallets.update { it?.updateWith(sensitiveInfo) } + } .doOnFailure { raise(UnlockWalletError.UnableToUnlock) } } @@ -319,12 +336,16 @@ internal class DefaultUserWalletsListRepository( } private suspend fun requestPasswordRecursive( + hotWalletId: HotWalletId, block: suspend (CharArray) -> UserWalletEncryptionKey?, biometryFallback: suspend () -> Either, ): Either { - val result = passwordRequester.requestPassword( + val attemptRequest = HotWalletPasswordRequester.AttemptRequest( + hotWalletId = hotWalletId, + authMode = true, // In auth mode user wallet can be deleted after 30 failed attempts hasBiometry = hasBiometry(), ) + val result = passwordRequester.requestPassword(attemptRequest) return when (result) { HotWalletPasswordRequester.Result.Dismiss -> { @@ -335,7 +356,7 @@ internal class DefaultUserWalletsListRepository( val decrypted = block(result.password.value) if (decrypted == null) { passwordRequester.wrongPassword() - requestPasswordRecursive(block, biometryFallback) + requestPasswordRecursive(hotWalletId, block, biometryFallback) } else { passwordRequester.successfulAuthentication() passwordRequester.dismiss() @@ -353,6 +374,12 @@ internal class DefaultUserWalletsListRepository( } } + private suspend fun removePasswordAttempts(userWallet: UserWallet) { + if (userWallet is UserWallet.Hot) { + hotWalletAccessCodeAttemptsRepository.resetAttempts(userWallet.hotWalletId) + } + } + private suspend fun hasBiometry(): Boolean { val useBiometricAuthentication = appPreferencesStore.getSyncOrDefault( key = PreferencesKeys.USE_BIOMETRIC_AUTHENTICATION_KEY, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 6c0e164b4e..06a5f51102 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -169,6 +169,18 @@ object PreferencesKeys { fun getShouldShowInitialPermissionScreen(permission: String) = booleanPreferencesKey("shouldShowInitialPushPermissionScreen_$permission") // endregion + + // region Hot Wallet unlock attempts + + fun getHotWalletUnlockAttemptsKey(attemptId: String) = + intPreferencesKey(name = "hotWalletUnlockAttempts_$attemptId") + + fun getHotWalletUnlockBootKey(attemptId: String) = intPreferencesKey(name = "hotWalletUnlockBootCount_$attemptId") + + fun getHotWalletUnlockDeadlineKey(attemptId: String) = + longPreferencesKey(name = "hotWalletUnlockDeadline_$attemptId") + + // endregion } /** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore */ diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt index 5564622241..bcaff10b6f 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/di/WalletsDataModule.kt @@ -5,6 +5,7 @@ import com.tangem.data.wallets.DefaultWalletsRepository import com.tangem.data.wallets.cold.DefaultColdMapDerivationsRepository import com.tangem.data.wallets.derivations.DefaultDerivationsRepository import com.tangem.data.wallets.hot.DefaultHotMapDerivationsRepository +import com.tangem.data.wallets.hot.DefaultHotWalletAccessCodeAttemptsRepository import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeStateStore @@ -13,6 +14,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.wallets.derivations.ColdMapDerivationsRepository import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -68,4 +70,10 @@ internal interface WalletsDataBindsModule { @Binds @Singleton fun bindColdMapDerivationsRepository(impl: DefaultColdMapDerivationsRepository): ColdMapDerivationsRepository + + @Binds + @Singleton + fun bindHotWalletAccessCodeAttemptsRepository( + impl: DefaultHotWalletAccessCodeAttemptsRepository, + ): HotWalletAccessCodeAttemptsRepository } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessCodeAttemptsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessCodeAttemptsRepository.kt new file mode 100644 index 0000000000..8a77f2f70a --- /dev/null +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotWalletAccessCodeAttemptsRepository.kt @@ -0,0 +1,138 @@ +package com.tangem.data.wallets.hot + +import android.content.Context +import android.os.SystemClock +import android.provider.Settings +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.ATTEMPTS_BEFORE_DELETION +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.COOLDOWN_SECONDS +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_ATTEMPTS_BEFORE_DELETION +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS +import com.tangem.hot.sdk.model.HotWalletId +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Suppress("MagicNumber") +class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val appPreferencesStore: AppPreferencesStore, +) : HotWalletAccessCodeAttemptsRepository { + + override suspend fun incrementAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) { + val attemptsKey = PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey()) + + appPreferencesStore.editData { preferences -> + val currentAttempts = preferences[attemptsKey] ?: 0 + val newAttempts = currentAttempts + 1 + + preferences[attemptsKey] = newAttempts + val currentBootCount = currentBootCount() + preferences[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] = currentBootCount + + if (newAttempts >= MAX_FAST_FORWARD_ATTEMPTS) { + val currentDeadline = SystemClock.elapsedRealtime() + COOLDOWN_SECONDS * 1000 + preferences[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] = currentDeadline + } + } + } + + override suspend fun resetAttempts(hotWalletId: HotWalletId) { + val authAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId( + hotWalletId = hotWalletId, + auth = true, + ) + val noAuthAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId( + hotWalletId = hotWalletId, + auth = false, + ) + + appPreferencesStore.editData { + it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey())) + it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey())) + it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey())) + it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey())) + it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey())) + it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey())) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Flow { + val flow = appPreferencesStore.data.map { + AttemptsPersistentData( + attempts = it[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0, + bootCount = it[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0, + deadline = it[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L, + ) + }.distinctUntilChanged() + + return flow.transformLatest { + while (true) { + emit(toState(id, it.attempts, it.deadline, it.bootCount)) + val remaining = remainingSeconds(it.deadline, it.bootCount) + if (remaining <= 0) break + delay(timeMillis = 1000) + } + }.distinctUntilChanged() + } + + override suspend fun getAttemptsSync(id: HotWalletAccessCodeAttemptsRepository.AttemptId): Attempts { + val prefs = appPreferencesStore.data.first() + val count = prefs[PreferencesKeys.getHotWalletUnlockAttemptsKey(id.attemptIdKey())] ?: 0 + val boot = prefs[PreferencesKeys.getHotWalletUnlockBootKey(id.attemptIdKey())] ?: 0 + val deadline = prefs[PreferencesKeys.getHotWalletUnlockDeadlineKey(id.attemptIdKey())] ?: 0L + return toState(id, count, deadline, boot) + } + + private fun remainingSeconds(deadline: Long, bootStored: Int): Int { + val now = SystemClock.elapsedRealtime() + val bootNow = currentBootCount() + if (bootNow != bootStored) { + // If the boot happened after the last attempt, we consider timer to start from the beginning + return maxOf(0, COOLDOWN_SECONDS - (now / 1000).toInt()) + } + return maxOf(0, ((deadline - now) / 1000).toInt()) + } + + private fun toState( + id: HotWalletAccessCodeAttemptsRepository.AttemptId, + count: Int, + deadlineElapsed: Long, + bootStored: Int, + ): Attempts { + val fast = MAX_FAST_FORWARD_ATTEMPTS + val attention = ATTEMPTS_BEFORE_DELETION + val deletion = MAX_ATTEMPTS_BEFORE_DELETION + + return when { + count < fast -> Attempts.FastForward(count) + id.auth && count >= deletion -> Attempts.Deletion + id.auth && count >= attention -> { + val remaining = remainingSeconds(deadlineElapsed, bootStored) + Attempts.BeforeDeletion(count, remaining, deletion - count) + } + else -> { + val remaining = remainingSeconds(deadlineElapsed, bootStored) + Attempts.WithDelay(count, remaining) + } + } + } + + private fun HotWalletAccessCodeAttemptsRepository.AttemptId.attemptIdKey(): String { + return "${hotWalletId.value}_$auth" + } + + private fun currentBootCount(): Int = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0) + + private data class AttemptsPersistentData( + val attempts: Int, + val bootCount: Int, + val deadline: Long, + ) +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt index b53cd193d1..b5ca40b2de 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/HotWalletAccessor.kt @@ -33,10 +33,16 @@ class HotWalletAccessor @Inject constructor( val auth = when (hotWalletId.authType) { HotWalletId.AuthType.NoPassword -> HotAuth.NoAuth - HotWalletId.AuthType.Password -> requestPassword(false) + HotWalletId.AuthType.Password -> requestPassword( + hotWalletId = hotWalletId, + hasBiometry = false, + ) HotWalletId.AuthType.Biometry -> { if (isAccessCodeRequired) { - requestPassword(false) + requestPassword( + hotWalletId = hotWalletId, + hasBiometry = false, + ) } else { HotAuth.Biometry } @@ -56,6 +62,7 @@ class HotWalletAccessor @Inject constructor( block: suspend (auth: HotAuth) -> T, ): T { return runCatchingWrongPassInternal( + hotWalletId = hotWalletId, originalAuth = auth, auth = auth, block = { blockAuth -> @@ -97,6 +104,7 @@ class HotWalletAccessor @Inject constructor( } private suspend fun runCatchingWrongPassInternal( + hotWalletId: HotWalletId, originalAuth: HotAuth, auth: HotAuth, block: suspend (auth: HotAuth) -> T, @@ -105,9 +113,13 @@ class HotWalletAccessor @Inject constructor( }.getOrElse { exception -> if (auth is HotAuth.Biometry && exception.isBiometryError()) { // fallback to password if biometry fails - val passAuth = requestPassword(true) + val passAuth = requestPassword( + hotWalletId = hotWalletId, + hasBiometry = true, + ) return@getOrElse runCatchingWrongPassInternal( + hotWalletId = hotWalletId, originalAuth = originalAuth, auth = passAuth, block = block, @@ -121,17 +133,28 @@ class HotWalletAccessor @Inject constructor( // If the exception is a wrong password, we need to request the password again hotWalletPasswordRequester.wrongPassword() - val passResult = requestPassword(originalAuth is HotAuth.Biometry) + val passResult = requestPassword( + hotWalletId = hotWalletId, + hasBiometry = originalAuth is HotAuth.Biometry, + ) runCatchingWrongPassInternal( + hotWalletId = hotWalletId, originalAuth = originalAuth, auth = passResult, block = block, ) } - private suspend fun requestPassword(hasBiometry: Boolean): HotAuth { - return hotWalletPasswordRequester.requestPassword(hasBiometry).toAuth() ?: throw TangemSdkError.UserCancelled() + private suspend fun requestPassword(hotWalletId: HotWalletId, hasBiometry: Boolean): HotAuth { + val attemptRequest = HotWalletPasswordRequester.AttemptRequest( + hotWalletId = hotWalletId, + authMode = false, + hasBiometry = hasBiometry, + ) + + return hotWalletPasswordRequester.requestPassword(attemptRequest).toAuth() + ?: throw TangemSdkError.UserCancelled() } private fun Throwable.isBiometryError(): Boolean { diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessCodeAttemptsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessCodeAttemptsRepository.kt new file mode 100644 index 0000000000..48c37f6440 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/hot/HotWalletAccessCodeAttemptsRepository.kt @@ -0,0 +1,71 @@ +package com.tangem.domain.wallets.hot + +import com.tangem.hot.sdk.model.HotWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Repository for managing access code attempts for hot wallets. + * It tracks the number of attempts made to access a hot wallet and applies cooldowns or deletion + * based on the number of attempts. + */ +interface HotWalletAccessCodeAttemptsRepository { + + /** + * Increments the number of attempts for the given [AttemptId]. + * If the number of attempts exceeds [MAX_FAST_FORWARD_ATTEMPTS], a cooldown period is initiated. + */ + suspend fun incrementAttempts(id: AttemptId) + + /** + * Resets the attempts for the given [HotWalletId]. + * This is typically called when the user successfully authenticates or when the wallet is deleted. + */ + suspend fun resetAttempts(hotWalletId: HotWalletId) + + /** + * Retrieves the current attempts for the given [AttemptId]. + * The result is a flow that emits the current state of attempts. + */ + fun getAttempts(id: AttemptId): Flow + + /** + * Synchronously retrieves the current attempts for the given [AttemptId]. + * This is useful when you need to get the attempts without using a flow. + */ + suspend fun getAttemptsSync(id: AttemptId): Attempts + + data class AttemptId( + val hotWalletId: HotWalletId, + val auth: Boolean, + ) + + sealed interface Attempts { + val count: Int + + data class FastForward( + override val count: Int, + ) : Attempts + + data class WithDelay( + override val count: Int, + val remainingSeconds: Int, + ) : Attempts + + data class BeforeDeletion( + override val count: Int, + val remainingSeconds: Int, + val remainingAttemptsCountBeforeDeletion: Int, + ) : Attempts + + data object Deletion : Attempts { + override val count: Int = MAX_ATTEMPTS_BEFORE_DELETION + } + } + + companion object { + const val COOLDOWN_SECONDS = 60 + const val MAX_FAST_FORWARD_ATTEMPTS = 5 + const val ATTEMPTS_BEFORE_DELETION = 20 + const val MAX_ATTEMPTS_BEFORE_DELETION = 30 + } +} \ No newline at end of file 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 7dcc5fa579..c3f3df8d95 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 @@ -1,17 +1,49 @@ package com.tangem.domain.wallets.hot import com.tangem.hot.sdk.model.HotAuth +import com.tangem.hot.sdk.model.HotWalletId +/** + * Interface for requesting the password for a hot wallet. + * It provides methods to handle password requests, authentication states, and user interactions. + */ interface HotWalletPasswordRequester { + /** + * Sets state to show wrong password state. + */ suspend fun wrongPassword() + /** + * Sets state to show successful authentication state. + */ suspend fun successfulAuthentication() - suspend fun requestPassword(hasBiometry: Boolean): Result + /** + * Requests the user to enter the password for the hot wallet. + * @param attemptRequest Contains information about the hot wallet and authentication mode. + * @return Result of the password request, which can be either a password entry, biometric use, or dismissal. + */ + suspend fun requestPassword(attemptRequest: AttemptRequest): Result + /** + * Dismisses the password request dialog. + */ suspend fun dismiss() + /** + * Represents a request to authenticate with a hot wallet. + * @param hotWalletId The ID of the hot wallet to authenticate with. + * @param authMode Indicates whether the request is for authentication mode. + * In auth mode user can be deleted after failed attempts. + * @param hasBiometry Indicates whether to show biometric authentication option. + */ + data class AttemptRequest( + val hotWalletId: HotWalletId, + val authMode: Boolean, + val hasBiometry: Boolean, + ) + sealed class Result { data object UseBiometry : Result() data object Dismiss : Result() 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 6777d34243..4dd7d01895 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 @@ -29,8 +29,10 @@ internal class DefaultHotAccessCodeRequestComponent @AssistedInject constructor( model.successfulAuthentication() } - override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result { - model.show(hasBiometry) + override suspend fun requestPassword( + attemptRequest: HotWalletPasswordRequester.AttemptRequest, + ): HotWalletPasswordRequester.Result { + model.show(attemptRequest) return model.waitResult() } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index f210270b41..4a75ad8438 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -3,37 +3,63 @@ package com.tangem.features.hotwallet.accesscoderequest import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.ui.components.fields.PinTextColor +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.core.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository +import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM +import com.tangem.features.hotwallet.impl.R import com.tangem.hot.sdk.model.HotAuth +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @ModelScoped internal class HotAccessCodeRequestModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, + private val userWalletsListRepository: UserWalletsListRepository, ) : Model() { private val result = MutableStateFlow(null) + private val currentRequest = MutableStateFlow(null) + private val attemptsRequestJobHolder = JobHolder() + + private val HotWalletPasswordRequester.AttemptRequest.attemptId + get() = HotWalletAccessCodeAttemptsRepository.AttemptId( + hotWalletId = hotWalletId, + auth = authMode, + ) val uiState: StateFlow field = MutableStateFlow(getInitialState()) - fun dismiss() { - result.value = HotWalletPasswordRequester.Result.Dismiss - dismissState() - } + suspend fun show(attemptRequest: HotWalletPasswordRequester.AttemptRequest) { + if (userWalletExists(attemptRequest.hotWalletId).not()) { + Timber.e("User wallet with id ${attemptRequest.hotWalletId} does not exist") + result.value = HotWalletPasswordRequester.Result.Dismiss + return + } - fun show(hasBiometry: Boolean) { + currentRequest.value = attemptRequest result.value = null // Reset the result when showing the dialog + subscribeToAttempts(id = attemptRequest.attemptId) uiState.update { it.copy( isShown = true, accessCode = "", - useBiometricVisible = hasBiometry, + useBiometricVisible = attemptRequest.hasBiometry, onAccessCodeChange = ::onAccessCodeChange, ) } @@ -43,7 +69,15 @@ internal class HotAccessCodeRequestModel @Inject constructor( return result.filterNotNull().first().also { result.value = null } } + fun dismiss() { + result.value = HotWalletPasswordRequester.Result.Dismiss + attemptsRequestJobHolder.cancel() + dismissState() + } + suspend fun wrongAccessCode() { + val currentRequest = currentRequest.value ?: return + hotAccessCodeAttemptsRepository.incrementAttempts(currentRequest.attemptId) uiState.update { it.copy( accessCodeColor = PinTextColor.WrongCode, @@ -54,6 +88,8 @@ internal class HotAccessCodeRequestModel @Inject constructor( } suspend fun successfulAuthentication() { + val currentRequest = currentRequest.value ?: return + hotAccessCodeAttemptsRepository.resetAttempts(currentRequest.hotWalletId) uiState.update { it.copy( accessCodeColor = PinTextColor.Success, @@ -92,6 +128,68 @@ internal class HotAccessCodeRequestModel @Inject constructor( } } + private fun subscribeToAttempts(id: HotWalletAccessCodeAttemptsRepository.AttemptId) { + fun remainingSecondsToText(remainingSeconds: Int): TextReference? { + return if (remainingSeconds > 0) { + resourceReference( + R.string.access_code_check_warining_wait, + wrappedList(remainingSeconds), + ) + } else { + null + } + } + + suspend fun collectAttempts(attempts: Attempts) { + when (attempts) { + is Attempts.FastForward -> { + /** ignore */ + } + is Attempts.WithDelay -> { + uiState.update { + it.copy( + wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds), + onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 } + ?: {}, + ) + } + } + is Attempts.BeforeDeletion -> { + uiState.update { + it.copy( + wrongAccessCodeText = remainingSecondsToText(attempts.remainingSeconds) + ?: resourceReference( + R.string.access_code_check_warining_delete, + wrappedList(attempts.remainingAttemptsCountBeforeDeletion), + ), + onAccessCodeChange = ::onAccessCodeChange.takeIf { attempts.remainingSeconds <= 0 } + ?: {}, + ) + } + } + Attempts.Deletion -> deleteUserWallet() + } + } + + modelScope.launch { + hotAccessCodeAttemptsRepository.getAttempts(id) + .collectLatest { attempts -> collectAttempts(attempts) } + }.saveIn(attemptsRequestJobHolder) + } + + private suspend fun userWalletExists(id: HotWalletId): Boolean { + return userWalletsListRepository.userWalletsSync() + .any { it is UserWallet.Hot && it.hotWalletId == id } + } + + private suspend fun deleteUserWallet() { + val currentRequest = currentRequest.value ?: return + val userWallet = userWalletsListRepository.userWalletsSync() + .firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return + userWalletsListRepository.delete(listOf(userWallet.walletId)) + dismiss() + } + private fun dismissState() { uiState.update { it.copy(isShown = false) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/entity/HotAccessCodeRequestUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/entity/HotAccessCodeRequestUM.kt index 62f2b4d051..78c3c269f6 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/entity/HotAccessCodeRequestUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/entity/HotAccessCodeRequestUM.kt @@ -1,11 +1,13 @@ package com.tangem.features.hotwallet.accesscoderequest.entity import com.tangem.core.ui.components.fields.PinTextColor +import com.tangem.core.ui.extensions.TextReference internal data class HotAccessCodeRequestUM( val isShown: Boolean = false, val accessCode: String = "", val accessCodeColor: PinTextColor = PinTextColor.Primary, + val wrongAccessCodeText: TextReference? = null, val useBiometricVisible: Boolean = true, val useBiometricClick: () -> Unit = {}, val onAccessCodeChange: (String) -> Unit = {}, 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 b7e7218a48..1968bae87c 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 @@ -13,20 +13,15 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR val componentRequester = MutableStateFlow(null) - override suspend fun wrongPassword() { - call { wrongPassword() } - } + override suspend fun wrongPassword() = call { wrongPassword() } - override suspend fun successfulAuthentication() { - call { successfulAuthentication() } - } + override suspend fun successfulAuthentication() = call { successfulAuthentication() } - override suspend fun requestPassword(hasBiometry: Boolean): HotWalletPasswordRequester.Result = - call { requestPassword(hasBiometry) } + override suspend fun requestPassword( + attemptRequest: HotWalletPasswordRequester.AttemptRequest, + ): HotWalletPasswordRequester.Result = call { requestPassword(attemptRequest) } - override suspend fun dismiss() { - call { dismiss() } - } + override suspend fun dismiss() = call { dismiss() } private suspend fun call(block: suspend HotWalletPasswordRequester.() -> T): T { return withTimeout(timeMillis = 1000) { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/ui/HotAccessCodeRequestFullScreenContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/ui/HotAccessCodeRequestFullScreenContent.kt index 9ae3230ec4..cc30961992 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/ui/HotAccessCodeRequestFullScreenContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/ui/HotAccessCodeRequestFullScreenContent.kt @@ -13,6 +13,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SecondaryButton @@ -22,6 +24,8 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.fields.PinTextColor import com.tangem.core.ui.components.fields.PinTextField +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager @@ -89,6 +93,33 @@ internal fun HotAccessCodeRequestFullScreenContent(state: HotAccessCodeRequestUM pinTextColor = state.accessCodeColor, onValueChange = state.onAccessCodeChange, ) + + SpacerH(20.dp) + + AnimatedVisibility( + modifier = Modifier.animateEnterExit( + enter = slideInVertically( + tween(), + initialOffsetY = { it + 200 }, + ) + fadeIn(tween()), + exit = slideOutVertically(tween(300)) { it - 200 } + fadeOut(tween()), + ), + visible = state.wrongAccessCodeText != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + val wrongAccessCodeText = + state.wrongAccessCodeText ?: return@AnimatedVisibility + + Text( + text = wrongAccessCodeText.resolveReference(), + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2.copy( + lineBreak = LineBreak.Heading, + ), + color = TangemTheme.colors.text.warning, + ) + } } if (state.useBiometricVisible) { @@ -132,7 +163,10 @@ private fun Preview() { var isShown by remember { mutableStateOf(true) } HotAccessCodeRequestFullScreenContent( - state = HotAccessCodeRequestUM(isShown = isShown), + state = HotAccessCodeRequestUM( + isShown = isShown, + wrongAccessCodeText = stringReference("Wrong access code"), + ), modifier = Modifier, ) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 1901cc427a..8ef83bf9ec 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -76,7 +76,13 @@ internal class WelcomeModel @Inject constructor( launch { walletsFetcher.userWallets - .collectLatest { wallets.value = it } + .collectLatest { + if (it.isEmpty()) { + router.replaceAll(AppRoute.Home()) + } + + wallets.value = it + } } tryToUnlockRightAway()