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

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