Implement Rust-owned adaptive Android password navigation (#96)
Android ARM verification / rust (push) Failing after 46m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 12:25:02 +02:00
1 parent 36e5f55283
commit 8c9341f214
26 files changed
+1553 -16

No files matched your search

@@ -0,0 +1,298 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.PasswordKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
val state by repository.state.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
var actions by remember { mutableStateOf(false) }
var passphrase by remember { mutableStateOf("") }
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
LaunchedEffect(entry?.id) {
passphrase = ""
navigator.navigateTo(
if (entry ==
null
) {
ListDetailPaneScaffoldRole.List
} else {
ListDetailPaneScaffoldRole.Detail
},
entry?.id
)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
passphrase = ""
repository.lock()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (state.revision.isNotEmpty()) repository.refresh()
}
DisposableEffect(repository) { onDispose { repository.lock() } }
BackHandler(state.selected != null || state.parent != null) {
actions = false
repository.back()
}
val actionLabel = stringResource(R.string.password_actions)
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(
enabled = !state.loading && (state.parent != null || state.selected != null),
onClick = repository::back
) {
Text(stringResource(R.string.password_back))
}
TextButton(enabled = !state.loading, onClick = repository::refresh) {
Text(stringResource(R.string.password_refresh))
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
modifier = Modifier.weight(1f),
listPane = {
AnimatedPane {
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.padding(16.dp)) {
Text(
state.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() }
)
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
if (state.loading) CircularProgressIndicator()
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (state.stale) Text(stringResource(R.string.password_changed))
}
}
if (!state.loading && state.error == null && state.rows.isEmpty()) {
item {
Text(
stringResource(R.string.password_empty),
Modifier.padding(16.dp)
)
}
}
items(state.rows, key = { it.id }) { row ->
val rowActions =
stringResource(R.string.password_actions_for, row.title)
val kindLabel = stringResource(
if (row.kind ==
PasswordKind.Folder
) {
R.string.password_folder
} else {
R.string.password_entry
}
)
ListItem(
headlineContent = { Text(row.title) },
supportingContent = { Text(row.detail) },
leadingContent = {
Icon(
painterResource(
when (row.icon) {
ShellIcon.Folder -> R.drawable.ic_folder
ShellIcon.Key -> R.drawable.ic_key
else -> error("Invalid Rust navigation icon")
}
),
null
)
},
trailingContent = {
TextButton(
modifier = Modifier.semantics {
contentDescription =
rowActions
},
enabled = !state.loading,
onClick = {
actions = true
repository.inspect(row.id, state.revision)
}
) { Text(actionLabel) }
},
modifier = Modifier.combinedClickable(
enabled = !state.loading,
onClickLabel = stringResource(R.string.password_open),
onLongClickLabel = rowActions,
onClick = {
actions = false
repository.activate(row.id, state.revision)
},
onLongClick = {
actions = true
repository.inspect(row.id, state.revision)
}
).semantics {
stateDescription = kindLabel
customActions = listOf(
CustomAccessibilityAction(rowActions) {
actions = true
repository.inspect(row.id, state.revision)
true
}
)
}
)
HorizontalDivider()
}
}
}
},
detailPane = {
AnimatedPane {
LazyColumn(
Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (entry == null) {
item { Text(stringResource(R.string.password_choose)) }
} else {
item {
Text(
entry.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics {
heading()
}
)
Text(entry.path)
if (state.loading) CircularProgressIndicator()
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
val sections = state.sections
if (sections == null) {
item {
Text(entry.detail)
OutlinedTextField(
passphrase,
{
passphrase = it
},
label = {
Text(stringResource(R.string.setup_passphrase))
},
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !state.loading
)
Button(enabled = !state.loading, onClick = {
val submitted = passphrase
passphrase =
""
repository.unlock(submitted)
}) { Text(stringResource(R.string.password_unlock)) }
}
} else {
item {
TextButton(onClick = repository::lock) {
Text(stringResource(R.string.password_lock))
}
}
sections.forEach { section ->
item {
Text(
section.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics {
heading()
}
)
}
items(section.fields, key = { it.id.toString() }) { field ->
ListItem(headlineContent = {
Text(field.label)
}, supportingContent = {
Column {
Text(field.value)
field.diagnostic?.let { Text(it) }
}
})
}
}
}
}
}
}
}
)
}
if (actions && state.selected != null && !state.loading) {
val selected = requireNotNull(state.selected)
AlertDialog(
onDismissRequest = { actions = false },
title = {
Text(selected.title)
},
text = { Text(selected.detail + "\n" + selected.path) },
confirmButton = {
TextButton(onClick = {
actions = false
repository.activate(selected.id, state.revision)
}) { Text(stringResource(R.string.password_open)) }
},
dismissButton = {
TextButton(onClick = {
actions = false
}) { Text(stringResource(R.string.password_close)) }
}
)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -52,7 +53,15 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge)
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
snapshot.selectedTab == ShellTab.Passwords
if (!browsing) {
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge
)
}
val page = snapshot.pages.firstOrNull { it.tab == snapshot.selectedTab }
val nativeFailure = snapshot.nativeFailure
val title = if (nativeFailure != null) {
@@ -80,6 +89,9 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) {
OnboardingScreen(viewModel.onboarding, setupForm)
} else if (browsing) {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
} else {
Text(
title,
@@ -1,18 +1,58 @@
package de.rfc1437.ironstorage.features
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellTab
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class ShellViewModel @Inject constructor(
private val repository: ShellRepository,
val onboarding: OnboardingRepository
val onboarding: OnboardingRepository,
val passwords: PasswordRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
init {
viewModelScope.launch {
passwords.state.collect { value ->
if (!value.loading && value.error == null && value.revision.isNotEmpty()) {
saved["password.path"] = value.path
saved["password.selection"] = value.selected?.id
saved["password.revision"] = value.revision
}
}
}
}
private var passwordsRestored = false
fun openPasswords() {
if (!passwordsRestored) {
passwordsRestored = true
passwords.restore(
saved["password.path"] ?: "",
saved["password.selection"],
saved["password.revision"]
)
} else {
passwords.refresh()
}
}
fun selectTab(tab: ShellTab) {
if (tab !=
ShellTab.Passwords
) {
passwords.lock()
}
repository.selectTab(tab)
}
override fun onCleared() {
passwords.lock()
}
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M10,4H2v16h20V6H12z" />
</vector>
@@ -30,4 +30,18 @@
<string name="setup_replace_token">Replace application token</string>
<string name="setup_back">Back to setup choices</string>
<string name="setup_retry">Reload setup state</string>
<string name="password_back">Back</string>
<string name="password_refresh">Refresh</string>
<string name="password_root">Password Store</string>
<string name="password_changed">The repository changed. Stale selections were cleared.</string>
<string name="password_empty">This folder is empty.</string>
<string name="password_choose">Select an entry to view it.</string>
<string name="password_actions">Actions</string>
<string name="password_actions_for">Actions for %1$s</string>
<string name="password_folder">Folder</string>
<string name="password_entry">Locked password entry</string>
<string name="password_open">Open</string>
<string name="password_close">Close</string>
<string name="password_unlock">Unlock</string>
<string name="password_lock">Lock</string>
</resources>