Implement structured Android entry viewer (#99)
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user