Implement Android TOTP cached discovery and search (#110)
Android ARM verification / rust (push) Failing after 49m58s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-12 20:38:33 +02:00
1 parent 0c8366d34f
commit f4a6edbc68
18 files changed
+1070 -165

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android
Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md).
CameraX GPG scanning and QR export are described in
[key transfer](../docs/android/key-transfer.md).
The TOTP destination uses [cached discovery and search](../docs/android/totp-discovery.md)
while locked, with authenticated incremental refresh for new or changed entries.
Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the
ARM-only runner/toolchain policy and artifact audits.
@@ -13,6 +13,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
@@ -70,19 +71,27 @@ class EntryViewerTest {
}
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed()
compose.onNodeWithContentDescription("Reveal PIN").performScrollTo()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("alice 日本語"))
compose.onNodeWithText("alice 日本語").assertIsDisplayed()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("123 456"))
compose.onNodeWithText("123 456").assertIsDisplayed()
compose.onNodeWithTag(
"entry-detail"
).performScrollToNode(hasContentDescription("Reveal PIN"))
compose.onNodeWithContentDescription("Reveal PIN")
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321"))
compose.onNodeWithText("654321").assertIsDisplayed()
compose.onNodeWithContentDescription("Hide PIN").performScrollTo().performClick()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Hide PIN"))
compose.onNodeWithContentDescription("Hide PIN").performClick()
compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithContentDescription("Copy PIN").performScrollTo()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Copy PIN"))
compose.onNodeWithContentDescription("Copy PIN")
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId)
compose.onNodeWithContentDescription("Edit PIN").performScrollTo().performClick()
compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Edit PIN"))
compose.onNodeWithContentDescription("Edit PIN").performClick()
compose.onNode(hasSetTextAction()).performTextReplacement("987654")
compose.onNodeWithText("Save").performClick()
assertEquals("987654", repository.saved)
@@ -0,0 +1,59 @@
package de.rfc1437.ironstorage
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.EntryPoints
import de.rfc1437.ironstorage.core.EditorFieldKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assume.assumeTrue
import org.junit.Test
class TotpDiscoveryFixtureTest {
/** Public test entry only, guarded by the retained fixture's exact key identity. */
@Test fun addPublicTotpForComputerUse(): Unit = runBlocking {
assumeTrue(InstrumentationRegistry.getArguments().getString("totpFixtureSetup") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java)
val setup = access.onboarding()
withContext(Dispatchers.Main.immediate) { setup.refresh() }
val before = withTimeout(30000) { setup.setup.first { !it.busy } }
check(
before.configured &&
before.keys.single().fingerprint == "0C1ABFD707B2103E495B42ACF34D59DF60521FE3"
)
val catalog = access.totp()
withContext(Dispatchers.Main.immediate) { catalog.enter(null, null) }
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
check(cached.error == null)
if (cached.rows.any { it.path == "discovery-fixture" }) {
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
return@runBlocking
}
val editor = access.editor()
withContext(Dispatchers.Main.immediate) {
editor.create("", "discovery-fixture", "public-provider-fixture-passphrase")
}
val created = withTimeout(30000) { editor.state.first { !it.busy } }
check(created.open && !created.locked && created.error == null) { created.error.orEmpty() }
withContext(Dispatchers.Main.immediate) {
editor.add(
EditorFieldKind.OtpUri,
null,
"otpauth://totp/Public:discovery?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Public"
)
}
withTimeout(30000) {
editor.state.first {
!it.busy &&
it.fields.any { field -> field.kind == EditorFieldKind.OtpUri }
}
}
withContext(Dispatchers.Main.immediate) { editor.save() }
val saved = withTimeout(30000) { editor.state.first { !it.busy && !it.open } }
check(saved.error == null)
withContext(Dispatchers.Main.immediate) { access.authentication().lock() }
}
}
@@ -19,12 +19,15 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.TotpDiscoveryPhase
import de.rfc1437.ironstorage.core.TotpDiscoveryProgress
import de.rfc1437.ironstorage.core.TotpRepository
import de.rfc1437.ironstorage.core.TotpState
import de.rfc1437.ironstorage.features.TotpScreen
@@ -118,6 +121,27 @@ class TotpScreenTest {
compose.onNodeWithTag("otp-code").assertDoesNotExist()
}
@Test fun discoveryShowsTypedProgressCancellationAndCachedQuery() {
val fixture = Fixture()
fixture.state.value = fixture.state.value.copy(
refreshing = true,
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Inspecting, 10, 4, 3, 2, 1)
)
display(fixture)
compose.onNodeWithText("Inspecting entries").assertIsDisplayed()
compose.onNodeWithText(
"Inspected: 4 / 10 · Cache hits: 3 · Matches: 2 · Unavailable: 1"
).assertIsDisplayed()
compose.onNodeWithTag("totp-progress").assertIsDisplayed()
compose.onNodeWithTag("totp-query").performTextInput("cached")
assertEquals("cached", fixture.state.value.query)
compose.onNodeWithText("Cancel").performClick()
compose.onNodeWithText(
"Cancelled. Completed checkpoints are retained; refresh to resume."
).assertIsDisplayed()
compose.onNodeWithText("Apple Watch").assertDoesNotExist()
}
@Test fun inspectDisposableTotpWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("otpInspect") == "true")
@@ -194,6 +218,9 @@ class TotpScreenTest {
}
override fun enter(selected: String?, revision: String?) = Unit
override fun reload() = Unit
override fun query(value: String) {
state.value = state.value.copy(query = value)
}
override fun open(id: String, revision: String) {
state.value =
state.value.copy(detail = true)
@@ -203,7 +230,12 @@ class TotpScreenTest {
state.value = state.value.copy(detail = false)
}
override fun discover(passphrase: String) = Unit
override fun cancel() = Unit
override fun cancel() {
state.value = state.value.copy(
refreshing = false,
progress = state.value.progress?.copy(phase = TotpDiscoveryPhase.Cancelled)
)
}
override fun leave() = viewer.leave()
}
}
@@ -3,9 +3,11 @@ package de.rfc1437.ironstorage
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.NativeAuthenticationRepository
import de.rfc1437.ironstorage.core.NativeCredentialRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.TotpRepository
@EntryPoint
@InstallIn(SingletonComponent::class)
@@ -13,4 +15,6 @@ interface ProviderFixtureAccess {
fun onboarding(): OnboardingRepository
fun authentication(): NativeAuthenticationRepository
fun credentials(): NativeCredentialRepository
fun editor(): EditorRepository
fun totp(): TotpRepository
}
@@ -0,0 +1,142 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTotpDiscoveryTest {
@Test fun cachedSearchRefreshRecoveryAndProcessRecreationUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
}
val directories =
PrivateDirectories(
roots[0].canonicalPath,
roots[1].canonicalPath,
roots[2].canonicalPath
)
val keystore = NativeEntryViewerTest.MemoryKeystore()
val session = NativePrivateSession(context, { directories }, keystore)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val viewer =
NativePasswordRepository(
session,
scope,
auth,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val catalog = NativeTotpRepository(session, auth, viewer, scope)
val passphrase = "public-discovery-fixture"
suspend fun catalogAction(action: () -> Unit): TotpState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { catalog.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
val setup = session.storage().androidOnboarding(session.backend())
val key =
requireNotNull(setup.generate("Discovery fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
auth.authenticated { native ->
for (name in listOf("first", "second", "ordinary")) {
val editor = native.beginCreateEntry("", name, passphrase)
val page = if (name != "ordinary") {
native.addEntryEditorField(
editor.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Public:$name?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
)
} else {
editor.page
}
native.saveEntryEditor(
editor.id,
page.fields.map {
MobileEntryEditorInput(
it.id,
it.name,
if (it.kind == MobileEntryEditorFieldKind.PASSWORD) {
"public-discovery-password"
} else {
it.value.orEmpty()
}
)
}
)
}
}
withContext(Dispatchers.Main.immediate) { auth.lock() }
assertEquals(2, catalogAction { catalog.enter(null, null) }.rows.size)
assertFalse(auth.state.value.unlocked)
val warm = catalogAction { catalog.discover("") }
assertNull(warm.error)
assertEquals(TotpDiscoveryPhase.Complete, warm.progress!!.phase)
assertEquals(3L, warm.progress.cacheHits)
assertFalse(auth.state.value.unlocked)
val latest = catalogAction {
catalog.query("first")
catalog.query("second")
catalog.query("missing")
}
assertEquals("missing", latest.query)
assertTrue(latest.rows.isEmpty())
assertEquals("first", catalogAction { catalog.query(" FIRST ") }.rows.single().title)
assertFalse(auth.state.value.unlocked)
val cache = File(roots[1], "ironstorage/totp-catalog.toml")
assertTrue(cache.isFile)
assertFalse(cache.readText().contains("otpauth://"))
// Only this isolated test catalog is damaged; production state is never reset.
cache.writeText("damaged fixture catalog")
assertFalse(catalogAction { catalog.query("") }.hasCatalog)
withContext(Dispatchers.Main.immediate) {
catalog.discover(passphrase)
catalog.cancel()
}
assertEquals(TotpDiscoveryPhase.Cancelled, catalog.state.value.progress!!.phase)
val recovered = catalogAction { catalog.discover(passphrase) }
assertNull(recovered.error)
assertEquals(2, recovered.rows.size)
assertEquals(0L, recovered.progress!!.cacheHits)
assertEquals(3L, recovered.progress.inspected)
withContext(Dispatchers.Main.immediate) { auth.lock() }
val reopened = NativePrivateSession(context, { directories }, keystore)
val recreatedAuth = NativeAuthenticationRepository(reopened, scope, Dispatchers.IO)
val recreatedViewer =
NativePasswordRepository(
reopened,
scope,
recreatedAuth,
Dispatchers.IO,
NativeEntryViewerTest.MemoryClipboard()
)
val recreated = NativeTotpRepository(reopened, recreatedAuth, recreatedViewer, scope)
withContext(Dispatchers.Main.immediate) { recreated.enter(null, null) }
val retained = withTimeout(30000) { recreated.state.first { !it.loading } }
assertNull(retained.error)
assertEquals(2, retained.rows.size)
assertFalse(recreatedAuth.state.value.unlocked)
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { it.deleteRecursively() }
}
}
}
@@ -76,6 +76,8 @@ class NativeAuthenticationRepository(
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> metadata(block: (MobileAuthentication) -> T): T = access(false, block)
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (mutable.value.busy) null else access(false, block)
@@ -3,11 +3,16 @@ package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryProgress
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -24,28 +29,68 @@ class NativeTotpRepository(
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = mutable
private val serial = Mutex()
private var searchEpoch = 0L
private var refreshEpoch = 0L
private var debounce: Job? = null
@Volatile private var epoch = 0L
@Volatile private var search: MobileTotpOperation? = null
@Volatile private var discovery: MobileTotpOperation? = null
init {
val cleanup = authentication.presentation.register(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.close() }
val cleanup = authentication.presentation.register(::cancelDiscovery)
val metadata = authentication.presentation.registerMetadata(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion {
cleanup.close()
metadata.close()
}
}
override fun enter(selected: String?, revision: String?) = load(selected, revision)
override fun reload() = load(null, null)
private fun load(selected: String?, revision: String?) = run { ticket ->
val rows = authentication.authenticated { it.cachedTotpNavigation() }
if (ticket == epoch) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
if (selected != null && revision != null) open(selected, revision)
override fun query(value: String) {
mutable.value = mutable.value.copy(query = value)
load(null, null, true)
}
private fun load(selected: String?, revision: String?, wait: Boolean = false) {
val ticket = ++searchEpoch
search?.cancel()
debounce?.cancel()
val query = mutable.value.query
mutable.value = mutable.value.copy(searching = true, error = null)
// Debounce only queues work; native calls finish under the serial gate after cancellation.
debounce = scope.launch {
if (wait) delay(200)
scope.launch {
serial.withLock {
if (ticket != searchEpoch) return@withLock
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
search = active
if (ticket != searchEpoch) active.cancel()
try {
val rows = authentication.metadata {
it.searchCachedTotpNavigation(query, active)
}
if (ticket == searchEpoch) {
mutable.value = mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog = rows != null
)
if (selected != null && revision != null) open(selected, revision)
}
} catch (error: Exception) {
if (ticket == searchEpoch) failure(error)
} finally {
if (search === active) search = null
active.close()
if (ticket ==
searchEpoch
) {
mutable.value = mutable.value.copy(searching = false)
}
}
}
}
}
}
override fun open(id: String, revision: String) {
@@ -58,74 +103,116 @@ class NativeTotpRepository(
mutable.value = mutable.value.copy(detail = false)
reload()
}
override fun discover(passphrase: String) = run { ticket ->
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
if (ticket != epoch) active.cancel()
try {
val rows = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.totpPage(active)
it.cachedTotpNavigation()
override fun discover(passphrase: String) {
if (mutable.value.refreshing) return
val ticket = ++refreshEpoch
mutable.value = mutable.value.copy(
refreshing = true,
error = null,
notice = null,
progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Preparing, 0, 0, 0, 0, 0)
)
scope.launch {
serial.withLock {
if (ticket != refreshEpoch) return@withLock
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
if (ticket != refreshEpoch) active.cancel()
try {
coroutineScope {
val polling = launch {
while (true) {
val progress =
withContext(Dispatchers.IO) { active.progress().facade() }
if (ticket ==
refreshEpoch
) {
mutable.value =
mutable.value.copy(progress = progress)
}
delay(100)
}
}
try {
val result = authentication.authenticated {
it.refreshTotpCatalog(active, passphrase.ifEmpty { null })
}
if (ticket ==
refreshEpoch
) {
mutable.value =
mutable.value.copy(
progress = result.progress.facade(),
notice = result.cacheNotice
)
}
} finally {
polling.cancelAndJoin()
}
}
} catch (error: Exception) {
if (ticket == refreshEpoch) failure(error)
} finally {
if (discovery === active) discovery = null
active.close()
if (ticket == refreshEpoch) {
// Keep loading true until the refreshed rows have been published.
if (mutable.value.error == null) reload()
mutable.value = mutable.value.copy(refreshing = false)
}
}
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
}
} finally {
discovery = null
active.close()
}
}
override fun cancel() {
epoch += 1
searchEpoch++
debounce?.cancel()
search?.cancel()
cancelDiscovery()
mutable.value = mutable.value.copy(searching = false)
}
private fun cancelDiscovery() {
refreshEpoch++
discovery?.cancel()
mutable.value = mutable.value.copy(loading = false)
mutable.value = mutable.value.copy(
refreshing = false,
progress = if (mutable.value.refreshing) {
mutable.value.progress?.copy(
phase = TotpDiscoveryPhase.Cancelled
)
} else {
mutable.value.progress
}
)
}
override fun leave() {
cancel()
viewer.leave()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.loading) return
val ticket = epoch
mutable.value = mutable.value.copy(loading = true, error = null)
scope.launch {
serial.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_private_recovery))
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(loading = false)
}
}
private fun failure(error: Exception) {
val text = when (error) {
is MobileAuthenticationFfiException.Failed -> error.title + "\n" + error.detail
is MobilePrivateStorageFfiException -> session.text(R.string.core_private_recovery)
is NativeBridgeException -> session.text(R.string.core_update_retry)
is java.io.IOException -> session.text(R.string.core_private_recovery)
is kotlinx.coroutines.CancellationException -> throw error
else -> throw error
}
mutable.value = mutable.value.copy(error = text)
}
}
private fun MobileTotpDiscoveryProgress.facade() = TotpDiscoveryProgress(
when (phase) {
MobileTotpDiscoveryPhase.PREPARING -> TotpDiscoveryPhase.Preparing
MobileTotpDiscoveryPhase.INSPECTING -> TotpDiscoveryPhase.Inspecting
MobileTotpDiscoveryPhase.SAVING -> TotpDiscoveryPhase.Saving
MobileTotpDiscoveryPhase.COMPLETE -> TotpDiscoveryPhase.Complete
MobileTotpDiscoveryPhase.CANCELLED -> TotpDiscoveryPhase.Cancelled
},
total.toLong(),
inspected.toLong(),
cacheHits.toLong(),
matches.toLong(),
unavailable.toLong()
)
@@ -2,13 +2,28 @@ package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class TotpDiscoveryPhase { Preparing, Inspecting, Saving, Complete, Cancelled }
data class TotpDiscoveryProgress(
val phase: TotpDiscoveryPhase,
val total: Long,
val inspected: Long,
val cacheHits: Long,
val matches: Long,
val unavailable: Long
)
data class TotpState(
val rows: List<PasswordRow> = emptyList(),
val loading: Boolean = false,
val refreshing: Boolean = false,
val searching: Boolean = false,
val query: String = "",
val progress: TotpDiscoveryProgress? = null,
val hasCatalog: Boolean = false,
val detail: Boolean = false,
val notice: String? = null,
val error: String? = null
) {
val loading get() = refreshing || searching
override fun toString() = "TotpState(loading=$loading, [REDACTED])"
}
interface TotpRepository {
@@ -16,6 +31,7 @@ interface TotpRepository {
val viewer: PasswordRepository
fun enter(selected: String?, revision: String?)
fun reload()
fun query(value: String)
fun open(id: String, revision: String)
fun back()
fun discover(passphrase: String)
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -24,15 +28,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.TotpDiscoveryPhase
import de.rfc1437.ironstorage.core.TotpRepository
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
@@ -67,46 +76,118 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit
val expanded = maxWidth >= 600.dp
Row(Modifier.fillMaxSize()) {
if (expanded || !state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = requestCatalog,
modifier = Modifier.weight(1f)
) {
item {
Text(
stringResource(R.string.entry_totp),
style = MaterialTheme.typography.headlineMedium
)
TextButton(
enabled = !state.loading && !lease.busy,
onClick = requestCatalog
) {
Text(stringResource(R.string.totp_refresh_catalog))
}
if (state.loading) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.auth_cancel))
LazyColumn(
Modifier.fillMaxSize().padding(16.dp).testTag("totp-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
stringResource(R.string.entry_totp),
style = MaterialTheme.typography.headlineMedium
)
TextButton(
enabled = !state.loading && !lease.busy,
onClick = requestCatalog
) {
Text(stringResource(R.string.totp_refresh_catalog))
}
OutlinedTextField(
value = state.query,
onValueChange = repository::query,
label = { Text(stringResource(R.string.totp_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("totp-query")
)
state.progress?.let { progress ->
Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
Text(
stringResource(
when (progress.phase) {
TotpDiscoveryPhase.Preparing ->
R.string.totp_preparing
TotpDiscoveryPhase.Inspecting ->
R.string.totp_inspecting
TotpDiscoveryPhase.Saving ->
R.string.totp_saving
TotpDiscoveryPhase.Complete ->
R.string.totp_complete
TotpDiscoveryPhase.Cancelled ->
R.string.totp_cancelled
}
)
)
Text(
stringResource(
R.string.totp_progress_counts,
progress.inspected,
progress.total,
progress.cacheHits,
progress.matches,
progress.unavailable
)
)
if (progress.total > 0) {
LinearProgressIndicator(
progress = {
(
progress.inspected.toFloat() /
progress.total
).coerceIn(
0f,
1f
)
},
modifier = Modifier.fillMaxWidth().testTag(
"totp-progress"
)
)
}
}
}
if (state.loading) {
if (state.searching ||
state.progress?.phase == TotpDiscoveryPhase.Preparing
) {
CircularProgressIndicator()
}
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
state.notice?.let { Text(it) }
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.hasCatalog) {
Text(stringResource(R.string.totp_needs_catalog))
} else if (state.rows.isEmpty() && !state.searching) {
Text(
stringResource(
if (state.query.isEmpty()) {
R.string.totp_empty
} else {
R.string.totp_no_matches
}
)
)
}
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.hasCatalog) {
Text(stringResource(R.string.totp_needs_catalog))
} else if (state.rows.isEmpty()) {
Text(stringResource(R.string.totp_empty))
}
}
items(state.rows, key = { it.id }) { row ->
Column(
Modifier.fillMaxWidth().clickable(
enabled = !state.loading,
role = Role.Button,
onClick = {
repository.open(row.id, row.revision)
}
).padding(vertical = 12.dp)
) {
Text(row.title, style = MaterialTheme.typography.titleMedium)
Text(row.detail)
items(state.rows, key = { it.id }) { row ->
Column(
Modifier.fillMaxWidth().clickable(
enabled = !state.loading,
role = Role.Button,
onClick = {
repository.open(row.id, row.revision)
}
).padding(vertical = 12.dp)
) {
Text(row.title, style = MaterialTheme.typography.titleMedium)
Text(row.detail)
}
}
}
}
@@ -247,6 +247,14 @@
<item quantity="other">%1$s seconds remaining</item>
</plurals>
<string name="totp_refresh_catalog">Refresh TOTP catalog</string>
<string name="totp_search">Search cached TOTP entries</string>
<string name="totp_no_matches">No cached TOTP entries match this search.</string>
<string name="totp_preparing">Preparing discovery</string>
<string name="totp_inspecting">Inspecting entries</string>
<string name="totp_saving">Saving catalog</string>
<string name="totp_complete">Discovery complete</string>
<string name="totp_cancelled">Cancelled. Completed checkpoints are retained; refresh to resume.</string>
<string name="totp_progress_counts">Inspected: %1$d / %2$d · Cache hits: %3$d · Matches: %4$d · Unavailable: %5$d</string>
<string name="totp_needs_catalog">Refresh the catalog after unlocking to find TOTP entries.</string>
<string name="totp_empty">No TOTP entries found.</string>
<string name="totp_choose">Choose a TOTP entry.</string>