Harden Android accessibility, localization and startup (#105)
Implement independent hardening and regression coverage. Keep #105 open for final feature and environment coverage after #106–#110; document the completion boundary and resume order.
This commit is contained in:
1 parent
43f660ace3
commit
6669b77f4e
55 files changed
+24445
-296
No files matched your search
+63
-18
@@ -63,7 +63,7 @@ fun EntryEditorScreen(repository: EditorRepository, authentication: Authenticati
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
state.title,
|
||||
state.title.ifEmpty { stringResource(R.string.editor_initial_title) },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
@@ -165,11 +165,39 @@ fun EntryEditorScreen(repository: EditorRepository, authentication: Authenticati
|
||||
})
|
||||
)
|
||||
field.diagnostic?.let { Text(it) }
|
||||
val revealLabel = stringResource(
|
||||
R.string.entry_field_action,
|
||||
stringResource(if (shown) R.string.editor_hide else R.string.editor_reveal),
|
||||
field.label
|
||||
)
|
||||
val removeLabel =
|
||||
stringResource(
|
||||
R.string.entry_field_action,
|
||||
stringResource(R.string.editor_remove),
|
||||
field.label
|
||||
)
|
||||
val upLabel =
|
||||
stringResource(
|
||||
R.string.entry_field_action,
|
||||
stringResource(R.string.editor_up),
|
||||
field.label
|
||||
)
|
||||
val downLabel =
|
||||
stringResource(
|
||||
R.string.entry_field_action,
|
||||
stringResource(R.string.editor_down),
|
||||
field.label
|
||||
)
|
||||
FlowRow {
|
||||
TextButton(onClick = {
|
||||
revealed =
|
||||
if (shown) revealed - field.id else revealed + field.id
|
||||
}) {
|
||||
TextButton(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = revealLabel
|
||||
},
|
||||
onClick = {
|
||||
revealed =
|
||||
if (shown) revealed - field.id else revealed + field.id
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (shown) R.string.editor_hide else R.string.editor_reveal
|
||||
@@ -177,9 +205,16 @@ fun EntryEditorScreen(repository: EditorRepository, authentication: Authenticati
|
||||
)
|
||||
}
|
||||
if (field.removable) {
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
repository.remove(field.id)
|
||||
}, modifier = Modifier.testTag("editor-remove-${field.id}")) {
|
||||
TextButton(
|
||||
enabled = !state.busy,
|
||||
onClick = {
|
||||
repository.remove(field.id)
|
||||
},
|
||||
modifier = Modifier.testTag("editor-remove-${field.id}").semantics {
|
||||
contentDescription =
|
||||
removeLabel
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.editor_remove))
|
||||
}
|
||||
}
|
||||
@@ -196,20 +231,30 @@ fun EntryEditorScreen(repository: EditorRepository, authentication: Authenticati
|
||||
).toUInt()
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("editor-up-${field.id}")
|
||||
modifier = Modifier.testTag("editor-up-${field.id}").semantics {
|
||||
contentDescription =
|
||||
upLabel
|
||||
}
|
||||
) {
|
||||
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}")) {
|
||||
TextButton(
|
||||
enabled = canMoveDown,
|
||||
onClick = {
|
||||
repository.reorder(
|
||||
field.id,
|
||||
(
|
||||
index +
|
||||
1
|
||||
).toUInt()
|
||||
)
|
||||
},
|
||||
modifier = Modifier.testTag("editor-down-${field.id}").semantics {
|
||||
contentDescription =
|
||||
downLabel
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.editor_down))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +156,13 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
stringResource(
|
||||
if (field.revealed) R.string.entry_hide else R.string.entry_reveal
|
||||
)
|
||||
val revealLabel =
|
||||
stringResource(R.string.entry_field_action, reveal, field.label)
|
||||
TextButton(
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
"$reveal ${field.label}"
|
||||
revealLabel
|
||||
},
|
||||
onClick = {
|
||||
if (field.revealed) {
|
||||
@@ -174,20 +176,23 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
) { Text(reveal) }
|
||||
}
|
||||
val copy = stringResource(R.string.entry_copy)
|
||||
val copyLabel = stringResource(R.string.entry_field_action, copy, field.label)
|
||||
TextButton(
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "$copy ${field.label}"
|
||||
contentDescription = copyLabel
|
||||
},
|
||||
onClick = { repository.copy(field.id) }
|
||||
) { Text(copy) }
|
||||
if (field.editable) {
|
||||
val edit = stringResource(R.string.entry_edit)
|
||||
val editLabel =
|
||||
stringResource(R.string.entry_field_action, edit, field.label)
|
||||
TextButton(
|
||||
enabled = !state.loading,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
"$edit ${field.label}"
|
||||
editLabel
|
||||
},
|
||||
onClick = { repository.edit(field.id) }
|
||||
) { Text(edit) }
|
||||
|
||||
+2
-2
@@ -84,14 +84,14 @@ fun OnboardingScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
state.title,
|
||||
state.title.ifEmpty { stringResource(R.string.setup_initial_title) },
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
state.detail,
|
||||
state.detail.ifEmpty { stringResource(R.string.setup_initial_detail) },
|
||||
color = if (state.failure) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
|
||||
@@ -142,7 +142,9 @@ fun PasswordScreen(
|
||||
item {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
state.title,
|
||||
state.title.ifEmpty {
|
||||
stringResource(R.string.password_initial_title)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.heading
|
||||
@@ -88,7 +89,12 @@ fun SearchScreen(
|
||||
CircularProgressIndicator()
|
||||
Text(stringResource(R.string.search_loading))
|
||||
} else if (state.error != null) {
|
||||
Text(requireNotNull(state.error), color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
state.error.orEmpty().ifEmpty {
|
||||
stringResource(R.string.search_recovery)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else if (state.cancelled) {
|
||||
Text(stringResource(R.string.search_cancelled))
|
||||
} else if (state.results.isEmpty()) {
|
||||
@@ -98,7 +104,13 @@ fun SearchScreen(
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.search_count, state.results.size))
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.search_results,
|
||||
state.results.size,
|
||||
state.results.size
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.weight(1f).fillMaxSize()) {
|
||||
|
||||
+20
-14
@@ -28,6 +28,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.semantics.contentDescription
|
||||
@@ -52,6 +54,8 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.ProviderStatus
|
||||
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: ShellViewModel, setup: SetupFormViewModel) {
|
||||
@@ -190,9 +194,10 @@ fun SettingsScreen(
|
||||
)
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_timeout_value,
|
||||
it.timeout.toString()
|
||||
pluralStringResource(
|
||||
R.plurals.settings_timeout_value,
|
||||
it.timeout.toInt(),
|
||||
it.timeout.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -288,12 +293,12 @@ fun SettingsScreen(
|
||||
if (state.transfer.open) KeyTransferDialog(repository, authentication)
|
||||
if (licenses) {
|
||||
val resources = LocalResources.current
|
||||
val text =
|
||||
remember(resources) {
|
||||
resources.openRawResource(R.raw.license).bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
val loading = stringResource(R.string.settings_license_loading)
|
||||
val text by produceState(loading, resources) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
licenses = false
|
||||
@@ -491,8 +496,8 @@ private fun KeyTransferDialog(
|
||||
val description =
|
||||
stringResource(
|
||||
R.string.settings_qr_frame,
|
||||
qr.sequence.toString(),
|
||||
qr.total.toString()
|
||||
qr.sequence.toInt(),
|
||||
qr.total.toInt()
|
||||
)
|
||||
Text(description)
|
||||
Canvas(
|
||||
@@ -552,10 +557,11 @@ private fun KeyTransferDialog(
|
||||
""
|
||||
}) { Text(stringResource(R.string.settings_qr_add)) }
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_qr_progress,
|
||||
state.transfer.received.toString(),
|
||||
state.transfer.total.toString()
|
||||
pluralStringResource(
|
||||
R.plurals.settings_qr_progress,
|
||||
state.transfer.total.toInt(),
|
||||
state.transfer.received.toInt(),
|
||||
state.transfer.total.toInt()
|
||||
)
|
||||
)
|
||||
state.transfer.ready?.let {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -81,6 +82,7 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
val rail = maxWidth >= 600.dp
|
||||
val largeFont = LocalDensity.current.fontScale >= 1.6f
|
||||
Scaffold(
|
||||
modifier = Modifier.imePadding(),
|
||||
bottomBar = {
|
||||
if (!rail) {
|
||||
NavigationBar(Modifier.testTag("shell-bottom-navigation")) {
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
<string name="search_loading">Searching names…</string>
|
||||
<string name="search_prompt">Enter an entry name, folder, or path.</string>
|
||||
<string name="search_empty">No matching entries.</string>
|
||||
<string name="search_count">Results: %1$d</string>
|
||||
<string name="search_open">Open entry</string>
|
||||
<string name="search_cancelled">Search cancelled. Refresh to try again.</string>
|
||||
<string name="shell_appearance">Appearance</string>
|
||||
@@ -168,7 +167,10 @@
|
||||
<string name="settings_edit_git">Edit Git author</string>
|
||||
<string name="settings_biometry_available">Strong biometric authentication is available</string>
|
||||
<string name="settings_biometry_unavailable">Strong biometric authentication is unavailable; use a GPG passphrase</string>
|
||||
<string name="settings_timeout_value">Inactivity timeout: %1$s seconds</string>
|
||||
<plurals name="settings_timeout_value">
|
||||
<item quantity="one">Inactivity timeout: %1$d second</item>
|
||||
<item quantity="other">Inactivity timeout: %1$d seconds</item>
|
||||
</plurals>
|
||||
<string name="settings_edit_timeout">Edit inactivity timeout</string>
|
||||
<string name="settings_lock_now">Lock now</string>
|
||||
<string name="settings_rebuild">Rebuild TOTP cache</string>
|
||||
@@ -197,15 +199,29 @@
|
||||
<string name="settings_private_warning">Private QR codes contain encrypted secret key material. Export only after checking the intended recipient. A private export requires its GPG passphrase.</string>
|
||||
<string name="settings_private_export">Export private key</string>
|
||||
<string name="settings_export_confirm">Confirm and export selected key</string>
|
||||
<string name="settings_qr_frame">QR frame %1$s of %2$s</string>
|
||||
<string name="settings_qr_frame">QR frame %1$d of %2$d</string>
|
||||
<string name="settings_previous">Previous frame</string>
|
||||
<string name="settings_next">Next frame</string>
|
||||
<string name="settings_qr_images">Choose QR images</string>
|
||||
<string name="settings_qr_images_detail">Select all frames of one transfer together. Opening another app clears the current transfer.</string>
|
||||
<string name="settings_qr_payload">QR payload or armored GPG key</string>
|
||||
<string name="settings_qr_add">Add transfer payload</string>
|
||||
<string name="settings_qr_progress">Received %1$s of %2$s frames</string>
|
||||
<plurals name="settings_qr_progress">
|
||||
<item quantity="one">Received %1$d of %2$d frame</item>
|
||||
<item quantity="other">Received %1$d of %2$d frames</item>
|
||||
</plurals>
|
||||
<string name="settings_make_default">Make imported private key the default</string>
|
||||
<string name="settings_replace_key">Confirm replacement of an existing key</string>
|
||||
<string name="settings_import_confirm">Confirm and import reconstructed key</string>
|
||||
<string name="setup_initial_title">Set up your password store</string>
|
||||
<string name="setup_initial_detail">Create a local store or clone an existing HTTPS repository.</string>
|
||||
<string name="editor_initial_title">Entry editor</string>
|
||||
<string name="password_initial_title">Passwords</string>
|
||||
<string name="search_recovery">Search is unavailable. Unlock the device or recover the store, then retry.</string>
|
||||
<string name="entry_field_action">%1$s %2$s</string>
|
||||
<plurals name="search_results">
|
||||
<item quantity="one">%1$d result</item>
|
||||
<item quantity="other">%1$d results</item>
|
||||
</plurals>
|
||||
<string name="settings_license_loading">Loading license…</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user