Implement Android TOTP cached discovery and search (#110)
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user