Implement structured Android entry viewer (#99)
Android ARM verification / rust (push) Failing after 47m18s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 22s

This commit is contained in:
hugo committed 2026-10-09 17:36:38 +02:00
1 parent 647dc1bb4b
commit c0ba6d43d8
29 files changed
+1768 -78

No files matched your search

@@ -0,0 +1,332 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordState
@OptIn(ExperimentalMaterial3Api::class)
fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordRepository) {
item {
var menu by remember { mutableStateOf(false) }
var edit by remember { mutableStateOf(false) }
TopAppBar(title = { Text(stringResource(R.string.entry_details)) }, actions = {
TextButton(enabled = !state.loading, onClick = { edit = true }) {
Text(stringResource(R.string.entry_edit))
}
TextButton(enabled = !state.loading, onClick = { menu = true }) {
Text(stringResource(R.string.entry_more))
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
PasswordMutation.entries.forEach { action ->
DropdownMenuItem(text = { Text(stringResource(action.label())) }, onClick = {
menu = false
repository.prepareMutation(action)
})
}
}
})
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
if (edit) {
AlertDialog(
onDismissRequest = { edit = false },
title = { Text(stringResource(R.string.entry_edit)) },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
state.sections?.flatMap {
it.fields
}?.filter { it.editable }?.forEach { field ->
TextButton(onClick = {
edit = false
repository.edit(field.id)
}) { Text(field.label) }
}
}
},
confirmButton = {
TextButton(onClick = {
edit = false
}) { Text(stringResource(R.string.auth_cancel)) }
},
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
)
}
}
state.notice?.let { item { Text(it) } }
state.totp?.let { totp ->
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() })
Text(totp.account)
Text(totp.code, style = MaterialTheme.typography.headlineMedium)
Text(
pluralStringResource(
R.plurals.entry_countdown,
totp.remainingSeconds.toInt(),
totp.remainingSeconds.toLong()
)
)
TextButton(enabled = !state.loading, onClick = repository::copyTotp) {
Text(stringResource(R.string.entry_copy_code))
}
}
}
}
state.sections?.forEach { section ->
item {
Text(
section.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() }
)
}
items(section.fields, key = { "field-${it.id}" }) { field ->
Column(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(field.label, style = MaterialTheme.typography.labelLarge)
if (field.selectable && !field.sensitive) {
val clipboard = remember(repository, field.id) {
object : Clipboard {
override suspend fun getClipEntry(): ClipEntry? = null
override suspend fun setClipEntry(clipEntry: ClipEntry?) {
repository.copy(field.id)
}
}
}
// Selection copy uses the complete storage field, including its cleanup policy.
CompositionLocalProvider(LocalClipboard provides clipboard) {
SelectionContainer { Text(field.value) }
}
} else {
Text(field.value)
}
field.detail?.let { Text(it) }
field.diagnostic?.let { Text(it, color = MaterialTheme.colorScheme.error) }
FlowRow {
if (field.sensitive) {
val reveal =
stringResource(
if (field.revealed) R.string.entry_hide else R.string.entry_reveal
)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription =
"$reveal ${field.label}"
},
onClick = {
if (field.revealed) {
repository.hide(
field.id
)
} else {
repository.reveal(field.id)
}
}
) { Text(reveal) }
}
val copy = stringResource(R.string.entry_copy)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription = "$copy ${field.label}"
},
onClick = { repository.copy(field.id) }
) { Text(copy) }
if (field.editable) {
val edit = stringResource(R.string.entry_edit)
TextButton(
enabled = !state.loading,
modifier = Modifier.semantics {
contentDescription =
"$edit ${field.label}"
},
onClick = { repository.edit(field.id) }
) { Text(edit) }
}
}
}
}
}
}
@Composable
fun EntryDialogs(state: PasswordState, repository: PasswordRepository) {
state.edit?.let { edit ->
var value by remember(edit) { mutableStateOf(edit.value) }
var revealed by remember(edit) { mutableStateOf(false) }
val systemClipboard = LocalClipboard.current
val clipboard = remember(repository, edit.id, systemClipboard) {
object : Clipboard {
override suspend fun getClipEntry() = systemClipboard.getClipEntry()
override suspend fun setClipEntry(clipEntry: ClipEntry?) {
repository.copy(edit.id)
}
}
}
AlertDialog(
onDismissRequest = repository::dismissEdit,
title = { Text(edit.label) },
text = {
CompositionLocalProvider(LocalClipboard provides clipboard) {
Column {
OutlinedTextField(
value,
onValueChange = {
value = it
},
label = { Text(edit.label) },
singleLine = !edit.multiline,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
imeAction = if (edit.multiline) {
ImeAction.Default
} else {
ImeAction.Done
},
keyboardType = if (edit.sensitive) {
KeyboardType.Password
} else {
KeyboardType.Text
}
),
keyboardActions = KeyboardActions(onDone = {
repository.saveEdit(value)
}),
visualTransformation = if (edit.sensitive && !revealed) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}
)
Text(stringResource(R.string.entry_copy_stored_hint))
if (edit.sensitive) {
TextButton(onClick = { revealed = !revealed }) {
Text(
stringResource(
if (revealed) R.string.entry_hide else R.string.entry_reveal
)
)
}
}
}
}
},
confirmButton = {
TextButton(enabled = !state.loading, onClick = { repository.saveEdit(value) }) {
Text(stringResource(R.string.entry_save))
}
},
dismissButton = {
TextButton(onClick = repository::dismissEdit) {
Text(stringResource(R.string.auth_cancel))
}
},
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 }
AlertDialog(
onDismissRequest = repository::dismissMutation,
title = { Text(stringResource(plan.action.label())) },
text = {
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)
)
}
}
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
}
@@ -31,6 +31,7 @@ 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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -203,7 +204,7 @@ fun PasswordScreen(
detailPane = {
AnimatedPane {
LazyColumn(
Modifier.fillMaxSize().padding(16.dp),
Modifier.fillMaxSize().padding(16.dp).testTag("entry-detail"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (entry == null) {
@@ -241,32 +242,7 @@ fun PasswordScreen(
}
}
} 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) }
}
})
}
}
entryViewer(state, repository)
}
}
}
@@ -284,6 +260,7 @@ fun PasswordScreen(
}
}
}
if (lease.unlocked && state.sections != null) EntryDialogs(state, repository)
if (manual && entry != null) {
PassphraseDialog(
stringResource(R.string.auth_manual),
@@ -79,4 +79,27 @@
<item quantity="one">Unlocked: %1$d second remaining</item>
<item quantity="other">Unlocked: %1$d seconds remaining</item>
</plurals>
<string name="entry_details">Entry details</string>
<string name="entry_edit">Edit</string>
<string name="entry_more">More</string>
<string name="entry_totp">One-time password</string>
<plurals name="entry_countdown">
<item quantity="one">Valid for %1$d second</item>
<item quantity="other">Valid for %1$d seconds</item>
</plurals>
<string name="entry_copy_code">Copy code</string>
<string name="entry_hide">Hide</string>
<string name="entry_reveal">Reveal</string>
<string name="entry_copy">Copy</string>
<string name="entry_save">Save</string>
<string name="entry_move">Move entry</string>
<string name="entry_copy_entry">Copy entry</string>
<string name="entry_delete">Delete entry</string>
<string name="entry_discard_editor">This action discards the open editor.</string>
<string name="entry_delete_confirm">Permanently delete this encrypted entry?</string>
<string name="entry_destination">Destination: %1$s</string>
<string name="entry_overwrite">Allow replacing the existing entry</string>
<string name="entry_overwrite_enabled">Replacing the existing entry confirmed</string>
<string name="entry_confirm">Confirm</string>
<string name="entry_copy_stored_hint">Selection copies the stored field. Save edits first to copy changes.</string>
</resources>