Updated on 2026-08-14
This commit is contained in:
parent
54cd8b7cd7
commit
1563b48e8a
14 changed files with 485 additions and 34 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue