Плавный прогрессбар 2fa

This commit is contained in:
2026-05-21 11:12:39 +03:00
parent 671f1f1c2a
commit 7dd4a43c3d
3 changed files with 49 additions and 13 deletions

View File

@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow 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.ui.elements.WallencScreenScaffold
import com.github.nullptroma.wallenc.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.TwoFaCodeState
import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState 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.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -98,8 +100,9 @@ fun TwoFaTokensScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val nowMillis by produceState(initialValue = System.currentTimeMillis()) { val nowMillis by produceState(initialValue = System.currentTimeMillis()) {
while (true) { while (true) {
value = System.currentTimeMillis() withFrameMillis { frameTimeMillis ->
delay(1000) value = frameTimeMillis
}
} }
} }
var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) } var editingToken by remember { mutableStateOf<TwoFaTokenRecord?>(null) }
@@ -160,12 +163,13 @@ fun TwoFaTokensScreen(
} }
} }
} }
val codeProgress = if (codeState == null) 0f else { val codeProgress = if (codeState == null) {
val period = item.periodSeconds.coerceAtLeast(1) 0f
val elapsed = (period - codeState.secondsUntilRefresh) } else {
.coerceAtLeast(0) totpPeriodProgress(nowMillis, item.periodSeconds)
.coerceAtMost(period) }
elapsed.toFloat() / period.toFloat() val secondsUntilRefresh = codeState?.let {
totpSecondsUntilRefresh(nowMillis, item.periodSeconds)
} }
Row( Row(
modifier = Modifier modifier = Modifier
@@ -183,10 +187,10 @@ fun TwoFaTokensScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
text = if (codeState != null) { text = if (secondsUntilRefresh != null) {
stringResource( stringResource(
R.string.two_fa_code_refresh_seconds, R.string.two_fa_code_refresh_seconds,
codeState.secondsUntilRefresh, secondsUntilRefresh,
) )
} else { } else {
stringResource(R.string.two_fa_code_invalid_secret) stringResource(R.string.two_fa_code_invalid_secret)
@@ -562,7 +566,9 @@ private fun TwoFaTokenEditDialog(
@Composable @Composable
private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { 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) buildTwoFaCodeState(token, nowMillis)
} }
} }

View File

@@ -11,9 +11,21 @@ data class TwoFaCodeState(
val secondsUntilRefresh: Int, 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? { fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? {
val period = token.periodSeconds.coerceAtLeast(1) val period = token.periodSeconds.coerceAtLeast(1)
val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0) val remaining = totpSecondsUntilRefresh(nowMillis, period)
val code = generateTotpCode( val code = generateTotpCode(
secret = token.secret, secret = token.secret,
nowMillis = nowMillis, nowMillis = nowMillis,

View File

@@ -33,6 +33,24 @@ class TwoFaTotpTest {
assertTrue(state.secondsUntilRefresh in 1..30) 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 @Test
fun buildTwoFaCodeStateReturnsNullForInvalidSecret() { fun buildTwoFaCodeStateReturnsNullForInvalidSecret() {
val token = TwoFaTokenRecord( val token = TwoFaTokenRecord(