Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-20 14:11:12 +03:00
parent 54cd8b7cd7
commit 1563b48e8a
14 changed files with 485 additions and 34 deletions

View file

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

View file

@ -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<TangemSdkManager>,
private val savePersistentInformation: ProviderSuspend<Boolean>,
private val appPreferencesStore: AppPreferencesStore,
private val hotWalletAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
) : UserWalletsListRepository {
override val userWallets = MutableStateFlow<List<UserWallet>?>(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<UnlockWalletError, Unit>,
): Either<UnlockWalletError, UserWalletEncryptionKey?> {
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,

View file

@ -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<Preferences> */

View file

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

View file

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

View file

@ -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 <T> 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 {

View file

@ -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<Attempts>
/**
* 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
}
}

View file

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

View file

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

View file

@ -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<HotWalletPasswordRequester.Result?>(null)
private val currentRequest = MutableStateFlow<HotWalletPasswordRequester.AttemptRequest?>(null)
private val attemptsRequestJobHolder = JobHolder()
private val HotWalletPasswordRequester.AttemptRequest.attemptId
get() = HotWalletAccessCodeAttemptsRepository.AttemptId(
hotWalletId = hotWalletId,
auth = authMode,
)
val uiState: StateFlow<HotAccessCodeRequestUM>
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)

View file

@ -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 = {},

View file

@ -13,20 +13,15 @@ class HotWalletPasswordRequesterProxy @Inject constructor() : HotWalletPasswordR
val componentRequester = MutableStateFlow<HotWalletPasswordRequester?>(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 <T> call(block: suspend HotWalletPasswordRequester.() -> T): T {
return withTimeout(timeMillis = 1000) {

View file

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

View file

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