feat(android): add structured entry editor and encrypted draft recovery (#103)
Android ARM verification / rust (push) Failing after 46m30s
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 22:11:37 +02:00
1 parent d22d71eb5e
commit 06575363a2
23 files changed
+2330 -21

No files matched your search

@@ -0,0 +1,457 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.LocalFocusManager
import androidx.compose.ui.platform.testTag
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorFieldKind
import de.rfc1437.ironstorage.core.EditorRepository
@Composable
fun EntryEditorScreen(repository: EditorRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var adding by remember { mutableStateOf<EditorFieldKind?>(null) }
var generating by remember { mutableStateOf(false) }
var revealed by remember { mutableStateOf(emptySet<ULong>()) }
val focus = LocalFocusManager.current
ProtectedInputSurface(authentication.presentation)
BackHandler { repository.requestClose() }
LazyColumn(
Modifier.fillMaxSize().imePadding().testTag("entry-editor"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
state.title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics {
heading()
}
)
Text(state.path)
FlowRow {
TextButton(onClick = {
repository.requestClose()
}) { Text(stringResource(R.string.editor_cancel)) }
Button(
enabled =
!state.busy && !state.locked && state.dirty && state.fields.isNotEmpty() &&
!lease.busy,
onClick = {
focus.clearFocus()
repository.save()
}
) { Text(stringResource(R.string.editor_save)) }
TextButton(enabled = !state.busy, onClick = {
repository.requestClose(authentication::lock)
}) {
Text(stringResource(R.string.password_lock))
}
}
if (state.busy || lease.busy) CircularProgressIndicator()
state.error?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("editor-error")
)
}
if (state.locked || state.fields.isEmpty()) {
Text(stringResource(R.string.editor_locked))
Button(enabled = !state.busy && !lease.busy, onClick = { repository.resume("") }) {
Text(stringResource(R.string.password_unlock))
}
TextButton(enabled = !state.busy && !lease.busy, onClick = { manual = true }) {
Text(stringResource(R.string.auth_manual))
}
} else {
FlowRow {
TextButton(enabled = !state.busy, onClick = {
adding = EditorFieldKind.Field
}) { Text(stringResource(R.string.editor_add_field)) }
TextButton(enabled = !state.busy, onClick = {
repository.add(EditorFieldKind.Note, null, "")
}) { Text(stringResource(R.string.editor_add_notes)) }
TextButton(enabled = !state.busy, onClick = {
adding = EditorFieldKind.OtpUri
}) { Text(stringResource(R.string.editor_add_otp)) }
TextButton(enabled = !state.busy, onClick = {
generating = true
}) { Text(stringResource(R.string.editor_generate)) }
}
}
}
itemsIndexed(state.fields, key = { _, field -> field.id.toString() }) { index, field ->
Column(
Modifier.fillMaxWidth().widthIn(max = 800.dp).padding(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (field.nameEditable) {
OutlinedTextField(
value = field.name.orEmpty(),
onValueChange = { repository.change(field.id, it, field.value) },
enabled = !state.busy && !lease.busy,
label = { Text(stringResource(R.string.editor_field_name)) },
modifier = Modifier.fillMaxWidth().testTag("editor-name-${field.id}"),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
})
)
}
val shown = field.id in revealed
OutlinedTextField(
value = field.value.orEmpty(),
onValueChange = { repository.change(field.id, field.name, it) },
enabled = !state.busy && !lease.busy && field.value != null,
label = { Text(field.label) },
modifier = Modifier.fillMaxWidth().testTag("editor-value-${field.id}"),
visualTransformation = if (shown) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
singleLine = !field.multiline,
minLines = if (field.multiline) 3 else 1,
keyboardOptions = KeyboardOptions(
keyboardType = if (shown) KeyboardType.Text else KeyboardType.Password,
autoCorrectEnabled = false,
imeAction = if (field.multiline) ImeAction.Default else ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = {
focus.moveFocus(androidx.compose.ui.focus.FocusDirection.Next)
})
)
field.diagnostic?.let { Text(it) }
FlowRow {
TextButton(onClick = {
revealed =
if (shown) revealed - field.id else revealed + field.id
}) {
Text(
stringResource(
if (shown) R.string.editor_hide else R.string.editor_reveal
)
)
}
if (field.removable) {
TextButton(enabled = !state.busy, onClick = {
repository.remove(field.id)
}, modifier = Modifier.testTag("editor-remove-${field.id}")) {
Text(stringResource(R.string.editor_remove))
}
}
if (field.reorderable) {
TextButton(
enabled =
!state.busy && index > 0 && state.fields[index - 1].reorderable,
onClick = {
repository.reorder(
field.id,
(
index -
1
).toUInt()
)
},
modifier = Modifier.testTag("editor-up-${field.id}")
) {
Text(stringResource(R.string.editor_up))
}
val canMoveDown = !state.busy && index < state.fields.lastIndex
TextButton(enabled = canMoveDown, onClick = {
repository.reorder(
field.id,
(
index +
1
).toUInt()
)
}, modifier = Modifier.testTag("editor-down-${field.id}")) {
Text(stringResource(R.string.editor_down))
}
}
}
}
}
}
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.editor_unlock_detail),
authentication.presentation,
onCancel = { manual = false },
onSubmit = {
manual = false
repository.resume(it)
}
)
}
adding?.let { kind ->
EditorAddDialog(repository, authentication, kind) { adding = null }
}
if (generating) {
EditorGenerateDialog(repository, authentication) { generating = false }
}
}
@Composable
fun EditorDiscardDialog(repository: EditorRepository) {
val state by repository.state.collectAsStateWithLifecycle()
if (!state.discardRequested) return
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = repository::keepEditing,
title = { Text(stringResource(R.string.editor_discard_title)) },
text = { Text(stringResource(R.string.editor_discard_detail)) },
confirmButton = {
TextButton(onClick = repository::confirmDiscard) {
Text(stringResource(R.string.editor_discard))
}
},
dismissButton = {
TextButton(onClick = repository::keepEditing) {
Text(stringResource(R.string.editor_keep))
}
}
)
}
@Composable
private fun EditorAddDialog(
repository: EditorRepository,
authentication: AuthenticationRepository,
kind: EditorFieldKind,
close: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
var submitted by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(state.busy, state.completedOperations) {
if (submitted != null && !state.busy && state.completedOperations != submitted) close()
}
var name by rememberProtectedInput(authentication.presentation)
var value by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
Text(
stringResource(
if (kind ==
EditorFieldKind.OtpUri
) {
R.string.editor_add_otp
} else {
R.string.editor_add_field
}
)
)
},
text = {
ProtectedInputSurface(authentication.presentation)
Column {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (kind ==
EditorFieldKind.Field
) {
OutlinedTextField(name, {
name = it
}, label = {
Text(stringResource(R.string.editor_field_name))
}, modifier = Modifier.testTag("editor-add-name"), singleLine = true)
}
OutlinedTextField(
value,
{
value = it
},
label = {
Text(stringResource(R.string.editor_field_value))
},
modifier = Modifier.testTag("editor-add-value"),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
)
)
}
},
confirmButton = {
TextButton(onClick = {
submitted = state.completedOperations
repository.add(
kind,
if (kind ==
EditorFieldKind.Field
) {
name
} else {
null
},
value
)
}) { Text(stringResource(R.string.editor_add)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@Composable
private fun EditorGenerateDialog(
repository: EditorRepository,
authentication: AuthenticationRepository,
close: () -> Unit
) {
val state by repository.state.collectAsStateWithLifecycle()
var length by remember { mutableStateOf(state.defaultLength.toString()) }
var noSymbols by remember { mutableStateOf(false) }
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = { Text(stringResource(R.string.editor_generate)) },
text = {
Column {
Text(stringResource(R.string.editor_generate_detail))
OutlinedTextField(
length,
{
length = it
},
label = {
Text(stringResource(R.string.editor_length))
},
supportingText = {
Text(
stringResource(R.string.editor_length_help, state.maximumLength.toInt())
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
FlowRow {
Checkbox(
noSymbols,
{ noSymbols = it },
modifier = Modifier.semantics {
contentDescription =
noSymbolsLabel
}
)
Text(stringResource(R.string.editor_no_symbols))
}
}
},
confirmButton = {
TextButton(onClick = {
repository.generate(
if (length.isBlank()) {
null
} else {
length.toUIntOrNull()
?: 0u
},
noSymbols
)
close()
}) { Text(stringResource(R.string.editor_generate_confirm)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@Composable
internal fun EditorCreationDialog(
authentication: AuthenticationRepository,
folder: Boolean,
close: () -> Unit,
create: (String) -> Unit
) {
var name by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
Text(
stringResource(
if (folder) R.string.editor_new_folder else R.string.editor_new_entry
)
)
},
text = {
ProtectedInputSurface(authentication.presentation)
OutlinedTextField(
name,
{
name = it
},
label = { Text(stringResource(R.string.editor_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(onDone = { create(name) })
)
},
confirmButton = {
TextButton(onClick = { create(name) }) { Text(stringResource(R.string.editor_create)) }
},
dismissButton = {
TextButton(onClick = close) { Text(stringResource(R.string.editor_cancel)) }
}
)
}
@@ -4,6 +4,7 @@ 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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -47,6 +48,7 @@ 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.EditorRepository
import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.ShellIcon
@@ -57,7 +59,8 @@ fun PasswordScreen(
repository: PasswordRepository,
authentication: AuthenticationRepository,
modifier: Modifier = Modifier,
leaveOnDispose: Boolean = true
leaveOnDispose: Boolean = true,
editor: EditorRepository? = null
) {
DisposableEffect(repository, authentication) {
onDispose {
@@ -67,6 +70,8 @@ fun PasswordScreen(
val state by repository.state.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
var actions by remember { mutableStateOf(false) }
var createFolder by remember { mutableStateOf<Boolean?>(null) }
val editorState = editor?.state?.collectAsStateWithLifecycle()?.value
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var requested by remember { mutableStateOf(false) }
@@ -110,6 +115,23 @@ fun PasswordScreen(
Text(stringResource(R.string.password_refresh))
}
}
if (editor != null) {
FlowRow {
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
createFolder =
false
}) {
Text(stringResource(R.string.editor_new_entry))
}
TextButton(enabled = !state.loading && editorState?.busy != true, onClick = {
createFolder =
true
}) {
Text(stringResource(R.string.editor_new_folder))
}
}
editorState?.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
@@ -255,6 +277,15 @@ fun PasswordScreen(
}
}
} else {
if (editor != null) {
item {
TextButton(enabled = !state.loading, onClick = {
editor.begin(entry.path)
}) {
Text(stringResource(R.string.editor_edit_entry))
}
}
}
entryViewer(state, repository)
}
}
@@ -263,6 +294,26 @@ fun PasswordScreen(
}
)
}
createFolder?.let { folder ->
if (editor != null) {
EditorCreationDialog(
authentication,
folder,
close = { createFolder = null },
create = { name ->
createFolder = null
if (folder) {
editor.createFolder(
state.path,
name
)
} else {
editor.create(state.path, name)
}
}
)
}
}
LaunchedEffect(lease.failure, state.loading) {
if (requested && !state.loading && !lease.busy) {
requested = false
@@ -57,6 +57,7 @@ private enum class ShellRoute(val tab: ShellTab?) {
@Composable
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
EditorDiscardDialog(viewModel.editor)
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
fun selectTab(tab: ShellTab) {
@@ -149,12 +150,20 @@ private fun ShellDestination(
viewModel: ShellViewModel,
setupForm: SetupFormViewModel
) {
val setup by viewModel.onboarding.setup.collectAsStateWithLifecycle()
val editor by viewModel.editor.state.collectAsStateWithLifecycle()
val ready =
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
(
snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked ||
(snapshot.state == ShellState.Empty && setup.configured)
)
when {
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || route == ShellRoute.Preferences) -> {
(
(snapshot.state == ShellState.Empty && !setup.configured) ||
route == ShellRoute.Preferences
) -> {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -176,12 +185,17 @@ private fun ShellDestination(
}
ready && route == ShellRoute.Passwords -> {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false
)
if (editor.open) {
EntryEditorScreen(viewModel.editor, viewModel.authentication)
} else {
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false,
editor = viewModel.editor
)
}
}
ready && route == ShellRoute.Search -> SearchScreen(
viewModel.search,
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.EditorRepository
import de.rfc1437.ironstorage.core.HomeRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository
@@ -26,6 +27,7 @@ class ShellViewModel @Inject constructor(
val home: HomeRepository,
val search: SearchRepository,
val authentication: AuthenticationRepository,
val editor: EditorRepository,
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
@@ -45,6 +47,12 @@ class ShellViewModel @Inject constructor(
}
init {
if (saved.get<Boolean>("editor.wasOpen") == true && !editor.state.value.open) {
editor.processRecreated()
}
viewModelScope.launch {
editor.state.collect { saved["editor.wasOpen"] = it.open }
}
val restored = saved.get<String>("shell.tab")?.let { name ->
ShellTab.entries.firstOrNull { it.name == name }
}
@@ -84,6 +92,10 @@ class ShellViewModel @Inject constructor(
}
fun refreshShell() = repository.refresh()
fun selectTab(tab: ShellTab) {
if (tab != snapshot.value.selectedTab && editor.state.value.open) {
editor.requestClose { selectTab(tab) }
return
}
if (tab != snapshot.value.selectedTab) leaveDestination()
saved["shell.tab"] = tab.name
repository.selectTab(tab)
@@ -118,4 +118,34 @@
<string name="shell_appearance_light">Light</string>
<string name="shell_appearance_dark">Dark</string>
<string name="shell_refresh">Reload state</string>
<string name="editor_cancel">Cancel</string>
<string name="editor_save">Save entry</string>
<string name="editor_locked">The draft is protected. Unlock to resume editing. No plaintext draft is saved on disk.</string>
<string name="editor_unlock_detail">Unlock the GPG key to resume this in-memory draft.</string>
<string name="editor_add_field">Add named field</string>
<string name="editor_add_notes">Add notes</string>
<string name="editor_add_otp">Add OTP URI</string>
<string name="editor_field_name">Field name</string>
<string name="editor_field_value">Field value</string>
<string name="editor_generate">Generate password</string>
<string name="editor_generate_detail">Generate a replacement password in the draft. Save the entry to apply it.</string>
<string name="editor_length">Password length</string>
<string name="editor_no_symbols">Use letters and numbers only</string>
<string name="editor_generate_confirm">Generate replacement</string>
<string name="editor_reveal">Reveal value</string>
<string name="editor_hide">Hide value</string>
<string name="editor_remove">Remove field</string>
<string name="editor_up">Move up</string>
<string name="editor_down">Move down</string>
<string name="editor_add">Add field</string>
<string name="editor_discard_title">Discard unsaved changes?</string>
<string name="editor_discard_detail">The entry draft will be discarded without changing the password store.</string>
<string name="editor_discard">Discard changes</string>
<string name="editor_keep">Keep editing</string>
<string name="editor_new_entry">New entry</string>
<string name="editor_new_folder">New folder</string>
<string name="editor_edit_entry">Edit entry</string>
<string name="editor_name">Name</string>
<string name="editor_create">Create</string>
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
</resources>