Плавный прогрессбар 2fa
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user