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

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