Implement confirmed Android tree mutation workflows (#108)
Android ARM verification / rust (push) Failing after 49m2s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 2m40s

This commit is contained in:
hugo committed 2026-10-12 13:06:22 +02:00
1 parent 6a864a8d7f
commit 316a875d63
22 files changed
+2170 -220

No files matched your search

@@ -281,66 +281,29 @@ fun EntryDialogs(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
state.mutation?.let { plan ->
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
ProtectedWindow()
AlertDialog(
onDismissRequest = repository::dismissMutation,
title = { Text(stringResource(plan.action.label())) },
text = {
ProtectedInputSurface()
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(plan.source)
if (plan.discardEditor) Text(stringResource(R.string.entry_discard_editor))
if (plan.action == PasswordMutation.Delete) {
Text(stringResource(R.string.entry_delete_confirm))
} else {
plan.destinations.forEach { candidate ->
TextButton(onClick = { destination = candidate.path }) {
Text(candidate.title + "\n" + candidate.detail)
}
}
selected?.let { Text(stringResource(R.string.entry_destination, it.path)) }
if (selected?.overwrite == true) {
TextButton(onClick = { overwrite = !overwrite }) {
Text(
stringResource(
if (overwrite) {
R.string.entry_overwrite_enabled
} else {
R.string.entry_overwrite
}
)
)
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !state.loading && (
plan.action == PasswordMutation.Delete ||
selected != null && (!selected.overwrite || overwrite)
),
onClick = { repository.mutate(destination, overwrite) }
) {
Text(stringResource(R.string.entry_confirm))
}
},
dismissButton = {
TextButton(onClick = repository::dismissMutation) {
Text(stringResource(R.string.auth_cancel))
}
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
internal fun PasswordMutation.label(
kind: de.rfc1437.ironstorage.core.PasswordKind = de.rfc1437.ironstorage.core.PasswordKind.Entry
) = when (this) {
PasswordMutation.Move -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_move_folder
} else {
R.string.entry_move
}
PasswordMutation.Copy -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_copy_folder
} else {
R.string.entry_copy_entry
}
PasswordMutation.Delete -> if (kind ==
de.rfc1437.ironstorage.core.PasswordKind.Folder
) {
R.string.mutation_delete_folder
} else {
R.string.entry_delete
}
}
private fun PasswordMutation.label() = when (this) {
PasswordMutation.Move -> R.string.entry_move
PasswordMutation.Copy -> R.string.entry_copy_entry
PasswordMutation.Delete -> R.string.entry_delete
}
@@ -0,0 +1,215 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordState
@Composable
internal fun MutationDialog(
plan: PasswordMutationPlan,
state: PasswordState,
repository: PasswordRepository,
authentication: AuthenticationRepository
) {
val lease by authentication.state.collectAsStateWithLifecycle()
var destination by remember(plan) { mutableStateOf<String?>(null) }
var overwrite by remember(plan, destination) { mutableStateOf(false) }
var discard by remember(plan) { mutableStateOf(false) }
var manual by remember(plan) { mutableStateOf(false) }
var requested by remember(plan) { mutableStateOf(false) }
val selected = plan.destinations.find { it.path == destination }
LaunchedEffect(lease.failure, lease.busy, state.loading) {
if (requested && !lease.busy && !state.loading) {
requested = false
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
) {
manual = true
}
}
}
val cancel = {
repository.dismissMutation()
if (lease.busy) authentication.cancel()
}
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = cancel,
title = { Text(stringResource(plan.action.label(plan.kind))) },
text = {
ProtectedInputSurface(authentication.presentation)
LazyColumn(
Modifier.fillMaxWidth().heightIn(max = 400.dp).testTag("mutation-destinations")
) {
item {
Text(plan.sourceTitle, style = MaterialTheme.typography.titleLarge)
Text(plan.source)
if (plan.kind == PasswordKind.Folder) {
Text(
pluralStringResource(
R.plurals.mutation_entries,
plan.entries.coerceAtMost(Int.MAX_VALUE.toUInt()).toInt(),
plan.entries.toString()
)
)
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
lease.failure?.let {
Text(it.title + "\n" + it.detail, color = MaterialTheme.colorScheme.error)
}
if (!lease.unlocked) {
TextButton(enabled = !lease.busy && !state.loading, onClick = {
requested = true
repository.unlockMutation("")
}) { Text(stringResource(R.string.password_unlock)) }
TextButton(enabled = !lease.busy, onClick = { manual = true }) {
Text(stringResource(R.string.auth_manual))
}
}
if (plan.hasOpenEditor) {
Text(
stringResource(
if (plan.hasDirtyEditor) {
R.string.mutation_dirty_editor
} else {
R.string.mutation_open_editor
}
)
)
Row(
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
discard = it
}
) {
Checkbox(discard, onCheckedChange = null)
Text(stringResource(R.string.mutation_discard_editor))
}
}
if (plan.action == PasswordMutation.Delete) {
Text(stringResource(R.string.mutation_delete_target, plan.source))
}
}
if (plan.action != PasswordMutation.Delete) {
items(plan.destinations, key = { it.path }) { candidate ->
Row(
Modifier.fillMaxWidth().padding(
start = (
candidate.depth.coerceAtMost(12u).toInt() *
12
).dp
)
.selectable(
selected = destination == candidate.path,
enabled =
candidate.unavailable == null,
role = Role.RadioButton,
onClick = { destination = candidate.path }
)
) {
RadioButton(
selected = destination == candidate.path,
onClick = null,
enabled =
candidate.unavailable == null
)
Column {
Text(candidate.title + "\n" + candidate.detail)
candidate.unavailable?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
}
}
selected?.let { target ->
item {
Text(stringResource(R.string.entry_destination, target.path))
if (target.overwrite) {
Text(stringResource(R.string.mutation_collision))
target.collisionTargets.forEach { Text(it) }
Row(
Modifier.fillMaxWidth().toggleable(
overwrite,
role = Role.Checkbox
) {
overwrite = it
}
) {
Checkbox(overwrite, onCheckedChange = null)
Text(stringResource(R.string.entry_overwrite))
}
}
}
}
}
}
},
confirmButton = {
TextButton(
enabled = lease.unlocked && !state.loading && !lease.busy &&
(!plan.hasOpenEditor || discard) &&
(
plan.action == PasswordMutation.Delete ||
selected != null && selected.unavailable == null &&
(!selected.overwrite || overwrite)
),
onClick = { repository.mutate(destination, overwrite, discard) }
) {
Text(stringResource(R.string.entry_confirm))
}
},
dismissButton = {
TextButton(onClick = cancel) { Text(stringResource(R.string.auth_cancel)) }
}
)
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.auth_manual_detail),
presentation = authentication.presentation,
onCancel = {
manual = false
authentication.cancel()
},
onSubmit = {
manual = false
requested = true
repository.unlockMutation(it)
}
)
}
}
@@ -1,8 +1,10 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -18,6 +20,8 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
@@ -25,13 +29,17 @@ 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.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -50,8 +58,10 @@ import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -150,6 +160,7 @@ fun PasswordScreen(
)
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
if (state.loading) CircularProgressIndicator()
state.notice?.let { Text(it) }
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
@@ -182,57 +193,121 @@ fun PasswordScreen(
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)
val mutationLabels = PasswordMutation.entries.associateWith { action ->
stringResource(
R.string.mutation_action_for,
stringResource(action.label(row.kind)),
row.title
)
}
key(row.id, row.revision) {
val swipe = rememberSwipeToDismissBoxState()
val swipeScope = rememberCoroutineScope()
SwipeToDismissBox(
state = swipe,
gesturesEnabled = !state.loading,
onDismiss = { direction ->
val action = if (direction ==
SwipeToDismissBoxValue.EndToStart
) {
PasswordMutation.Delete
} else {
PasswordMutation.Move
}
) { 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)
repository.prepareRowMutation(row.id, row.revision, action)
swipeScope.launch { swipe.reset() }
},
onLongClick = {
actions = true
repository.inspect(row.id, state.revision)
backgroundContent = {
val action = if (swipe.dismissDirection ==
SwipeToDismissBoxValue.EndToStart
) {
PasswordMutation.Delete
} else {
PasswordMutation.Move
}
Box(
Modifier.fillMaxSize().background(
MaterialTheme.colorScheme.surfaceContainerHigh
).padding(16.dp),
contentAlignment = if (action ==
PasswordMutation.Delete
) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
}
) {
Text(mutationLabels.getValue(action))
}
}
).semantics {
stateDescription = kindLabel
customActions = listOf(
CustomAccessibilityAction(rowActions) {
actions = true
repository.inspect(row.id, state.revision)
true
) {
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
}
) + PasswordMutation.entries.map { action ->
CustomAccessibilityAction(
mutationLabels.getValue(action)
) {
actions = false
repository.prepareRowMutation(
row.id,
row.revision,
action
)
true
}
}
}
)
}
)
}
HorizontalDivider()
}
}
@@ -331,6 +406,7 @@ fun PasswordScreen(
) {
EntryDialogs(state, repository, authentication)
}
state.mutation?.let { MutationDialog(it, state, repository, authentication) }
if (manual && entry != null) {
PassphraseDialog(
stringResource(R.string.auth_manual),
@@ -363,7 +439,15 @@ fun PasswordScreen(
},
text = {
ProtectedInputSurface()
Text(selected.detail + "\n" + selected.path)
Column {
Text(selected.detail + "\n" + selected.path)
PasswordMutation.entries.forEach { action ->
TextButton(onClick = {
actions = false
repository.prepareRowMutation(selected.id, selected.revision, action)
}) { Text(stringResource(action.label(selected.kind))) }
}
}
},
confirmButton = {
TextButton(onClick = {
@@ -253,4 +253,17 @@
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string>
<string name="mutation_action_for">%1$s %2$s</string>
<string name="mutation_move_folder">Move folder</string>
<string name="mutation_copy_folder">Copy folder</string>
<string name="mutation_delete_folder">Delete folder</string>
<string name="mutation_delete_target">Delete %1$s and its contents? This requires confirmation.</string>
<string name="mutation_open_editor">This target has an open editor.</string>
<string name="mutation_dirty_editor">This target has unsaved edits.</string>
<string name="mutation_discard_editor">Discard its open editors and unsaved edits</string>
<string name="mutation_collision">These existing entries will be replaced:</string>
<plurals name="mutation_entries">
<item quantity="one">%1$s password entry</item>
<item quantity="other">%1$s password entries</item>
</plurals>
</resources>