From 7dd4a43c3dcd490ddb5603393accc29b42a1fb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Thu, 21 May 2026 11:12:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B1=D0=B0?= =?UTF-8?q?=D1=80=202fa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../storage/twofa/TwoFaTokensScreen.kt | 30 +++++++++++-------- .../nullptroma/wallenc/usecases/TwoFaTotp.kt | 14 ++++++++- .../wallenc/usecases/TwoFaTotpTest.kt | 18 +++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 0e2fc87..0ef6999 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -83,7 +84,8 @@ import com.github.nullptroma.wallenc.ui.elements.WallencScreenContentPadding import com.github.nullptroma.wallenc.ui.elements.WallencScreenScaffold import com.github.nullptroma.wallenc.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState -import kotlinx.coroutines.delay +import com.github.nullptroma.wallenc.usecases.totpPeriodProgress +import com.github.nullptroma.wallenc.usecases.totpSecondsUntilRefresh import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -98,8 +100,9 @@ fun TwoFaTokensScreen( val scope = rememberCoroutineScope() val nowMillis by produceState(initialValue = System.currentTimeMillis()) { while (true) { - value = System.currentTimeMillis() - delay(1000) + withFrameMillis { frameTimeMillis -> + value = frameTimeMillis + } } } var editingToken by remember { mutableStateOf(null) } @@ -160,12 +163,13 @@ fun TwoFaTokensScreen( } } } - val codeProgress = if (codeState == null) 0f else { - val period = item.periodSeconds.coerceAtLeast(1) - val elapsed = (period - codeState.secondsUntilRefresh) - .coerceAtLeast(0) - .coerceAtMost(period) - elapsed.toFloat() / period.toFloat() + val codeProgress = if (codeState == null) { + 0f + } else { + totpPeriodProgress(nowMillis, item.periodSeconds) + } + val secondsUntilRefresh = codeState?.let { + totpSecondsUntilRefresh(nowMillis, item.periodSeconds) } Row( modifier = Modifier @@ -183,10 +187,10 @@ fun TwoFaTokensScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = if (codeState != null) { + text = if (secondsUntilRefresh != null) { stringResource( R.string.two_fa_code_refresh_seconds, - codeState.secondsUntilRefresh, + secondsUntilRefresh, ) } else { stringResource(R.string.two_fa_code_invalid_secret) @@ -562,7 +566,9 @@ private fun TwoFaTokenEditDialog( @Composable private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { - return remember(token, nowMillis) { + val period = token.periodSeconds.coerceAtLeast(1) + val periodSlot = nowMillis / 1000L / period + return remember(token, periodSlot) { buildTwoFaCodeState(token, nowMillis) } } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt index 0b7e11a..784e814 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt @@ -11,9 +11,21 @@ data class TwoFaCodeState( val secondsUntilRefresh: Int, ) +/** Доля прошедшего TOTP-периода [0f, 1f] для плавного progress bar. */ +fun totpPeriodProgress(nowMillis: Long, periodSeconds: Int): Float { + val period = periodSeconds.coerceAtLeast(1) + val elapsedSec = (nowMillis / 1000.0) % period + return (elapsedSec / period).toFloat().coerceIn(0f, 1f) +} + +fun totpSecondsUntilRefresh(nowMillis: Long, periodSeconds: Int): Int { + val period = periodSeconds.coerceAtLeast(1) + return (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0) +} + fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { val period = token.periodSeconds.coerceAtLeast(1) - val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0) + val remaining = totpSecondsUntilRefresh(nowMillis, period) val code = generateTotpCode( secret = token.secret, nowMillis = nowMillis, diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt index c027ea9..ebfc646 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt @@ -33,6 +33,24 @@ class TwoFaTotpTest { assertTrue(state.secondsUntilRefresh in 1..30) } + @Test + fun totpPeriodProgressIsContinuousWithinPeriod() { + val period = 30 + val periodStartMillis = 1_700_000_100_000L // epoch sec кратна period + assertEquals(0f, totpPeriodProgress(periodStartMillis, period), 0.001f) + assertEquals(0.5f, totpPeriodProgress(periodStartMillis + 15_000L, period), 0.001f) + assertEquals(29f / 30f, totpPeriodProgress(periodStartMillis + 29_000L, period), 0.001f) + } + + @Test + fun totpSecondsUntilRefreshCountsDownWithinPeriod() { + val period = 30 + val t0 = 1_700_000_100_000L + assertEquals(30, totpSecondsUntilRefresh(t0, period)) + assertEquals(15, totpSecondsUntilRefresh(t0 + 15_000L, period)) + assertEquals(1, totpSecondsUntilRefresh(t0 + 29_000L, period)) + } + @Test fun buildTwoFaCodeStateReturnsNullForInvalidSecret() { val token = TwoFaTokenRecord(