Implement complete Android settings and recovery controls (#104)
This commit is contained in:
1 parent
06575363a2
commit
43f660ace3
34 files changed
+2720
-64
No files matched your search
+2
-2
@@ -28,10 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
|
||||
@Composable
|
||||
fun AuthenticationControls(repository: AuthenticationRepository) {
|
||||
fun AuthenticationControls(repository: AuthenticationRepository, reload: Boolean = true) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var enrollment by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(repository) { repository.reload() }
|
||||
LaunchedEffect(repository, reload) { if (reload) repository.reload() }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.auth_title))
|
||||
|
||||
+1
-1
@@ -340,7 +340,7 @@ private fun Field(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecretField(
|
||||
internal fun SecretField(
|
||||
label: String,
|
||||
value: String,
|
||||
singleLine: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
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.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.ProviderStatus
|
||||
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: ShellViewModel, setup: SetupFormViewModel) {
|
||||
SettingsScreen(
|
||||
viewModel.settings,
|
||||
viewModel.authentication,
|
||||
viewModel.onboarding,
|
||||
setup,
|
||||
viewModel::openAppearance
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
repository: SettingsRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
onboarding: OnboardingRepository,
|
||||
setup: SetupFormViewModel,
|
||||
openAppearance: () -> Unit
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val authenticationState by authentication.state.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var edit by remember { mutableStateOf<String?>(null) }
|
||||
var showSetup by remember { mutableStateOf(false) }
|
||||
var licenses by remember { mutableStateOf(false) }
|
||||
val uri = LocalUriHandler.current
|
||||
val setupState by onboarding.setup.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(setupState.busy, setupState.connected, setupState.selectedKey) {
|
||||
if (!setupState.busy) repository.reload()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.reload() }
|
||||
DisposableEffect(repository) { onDispose { repository.cancel() } }
|
||||
val headings = listOf(
|
||||
"store" to stringResource(R.string.settings_store),
|
||||
"git" to stringResource(R.string.settings_git),
|
||||
"auth" to stringResource(R.string.settings_auth),
|
||||
"cache" to stringResource(R.string.settings_cache),
|
||||
"providers" to stringResource(R.string.settings_providers),
|
||||
"recovery" to stringResource(R.string.settings_recovery),
|
||||
"about" to stringResource(R.string.settings_about)
|
||||
)
|
||||
val matches = headings.filter { (_, title) -> title.contains(query, ignoreCase = true) }
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().testTag("settings-list"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.settings_title),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
OutlinedTextField(
|
||||
query,
|
||||
{
|
||||
query = it
|
||||
},
|
||||
label = { Text(stringResource(R.string.settings_search)) },
|
||||
modifier = Modifier.fillMaxWidth().testTag("settings-search"),
|
||||
singleLine = true
|
||||
)
|
||||
if (state.busy) {
|
||||
CircularProgressIndicator()
|
||||
TextButton(onClick = repository::cancel) {
|
||||
Text(stringResource(R.string.auth_cancel))
|
||||
}
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
state.notice?.let { Text(it) }
|
||||
TextButton(onClick = repository::reload) {
|
||||
Text(stringResource(R.string.settings_reload))
|
||||
}
|
||||
}
|
||||
for ((id, title) in matches) {
|
||||
item(key = id) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
}
|
||||
)
|
||||
val page = state.page
|
||||
when (id) {
|
||||
"store" -> {
|
||||
page?.let {
|
||||
Text(it.repositoryTitle)
|
||||
Text(it.repositoryUrl)
|
||||
Text(it.serverTitle)
|
||||
Text(it.serverIdentity)
|
||||
Text(it.defaultKeyTitle)
|
||||
Text(it.defaultKeyFingerprint)
|
||||
Text(it.account ?: stringResource(R.string.settings_no_token))
|
||||
}
|
||||
Text(stringResource(R.string.settings_token_hidden))
|
||||
TextButton(onClick = {
|
||||
showSetup = !showSetup
|
||||
}) { Text(stringResource(R.string.settings_remote_token)) }
|
||||
TextButton(enabled = !state.busy && page?.account != null, onClick = {
|
||||
edit =
|
||||
"remove"
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_remove_token))
|
||||
}
|
||||
TextButton(enabled = !state.busy, onClick = repository::beginTransfer) {
|
||||
Text(stringResource(R.string.settings_key_transfer))
|
||||
}
|
||||
if (showSetup) {
|
||||
OnboardingScreen(
|
||||
onboarding,
|
||||
setup,
|
||||
scroll = false
|
||||
)
|
||||
}
|
||||
}
|
||||
"git" -> {
|
||||
page?.let {
|
||||
Text(it.gitName)
|
||||
Text(it.gitEmail)
|
||||
}
|
||||
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||
edit = "git"
|
||||
}) { Text(stringResource(R.string.settings_edit_git)) }
|
||||
}
|
||||
"auth" -> {
|
||||
page?.let {
|
||||
Text(
|
||||
stringResource(
|
||||
if (it.biometricAvailable) {
|
||||
R.string.settings_biometry_available
|
||||
} else {
|
||||
R.string.settings_biometry_unavailable
|
||||
}
|
||||
)
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_timeout_value,
|
||||
it.timeout.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||
edit =
|
||||
"timeout"
|
||||
}) { Text(stringResource(R.string.settings_edit_timeout)) }
|
||||
AuthenticationControls(authentication, reload = false)
|
||||
TextButton(onClick = authentication::lock) {
|
||||
Text(stringResource(R.string.settings_lock_now))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
setup.clearSecrets()
|
||||
openAppearance()
|
||||
}, modifier = Modifier.testTag("shell-open-appearance")) {
|
||||
Text(stringResource(R.string.shell_appearance))
|
||||
}
|
||||
}
|
||||
"cache" -> {
|
||||
page?.let { Text(it.cacheDetail) }
|
||||
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||
if (authenticationState.unlocked) {
|
||||
repository.rebuildCache("")
|
||||
} else {
|
||||
edit = "cache"
|
||||
}
|
||||
}) { Text(stringResource(R.string.settings_rebuild)) }
|
||||
}
|
||||
"providers" -> {
|
||||
page?.let {
|
||||
ProviderSetting(
|
||||
stringResource(R.string.settings_autofill),
|
||||
it.autofill,
|
||||
repository::openAutofill
|
||||
)
|
||||
ProviderSetting(
|
||||
stringResource(R.string.settings_credentials),
|
||||
it.credentials,
|
||||
repository::openCredentials
|
||||
)
|
||||
}
|
||||
}
|
||||
"recovery" -> {
|
||||
Text(stringResource(R.string.settings_recovery_key))
|
||||
Text(stringResource(R.string.settings_recovery_store))
|
||||
Text(stringResource(R.string.settings_recovery_cache))
|
||||
Text(stringResource(R.string.settings_offline))
|
||||
}
|
||||
"about" -> {
|
||||
page?.let {
|
||||
Text(stringResource(R.string.settings_version, it.version))
|
||||
}
|
||||
Text(stringResource(R.string.settings_privacy))
|
||||
TextButton(onClick = {
|
||||
licenses = true
|
||||
}) { Text(stringResource(R.string.settings_licenses)) }
|
||||
TextButton(onClick = {
|
||||
uri.openUri("https://git.rfc1437.de/hugo/IronStorage/issues")
|
||||
}) { Text(stringResource(R.string.settings_support)) }
|
||||
TextButton(onClick = {
|
||||
uri.openUri(
|
||||
"https://git.rfc1437.de/hugo/IronStorage/" +
|
||||
"src/branch/main/android/dependency-policy.json"
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_dependency_licenses))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit?.let { action ->
|
||||
if (action ==
|
||||
"cache"
|
||||
) {
|
||||
PassphraseDialog(
|
||||
stringResource(R.string.settings_rebuild),
|
||||
stringResource(R.string.settings_rebuild_detail),
|
||||
authentication.presentation,
|
||||
onCancel = { edit = null },
|
||||
onSubmit = {
|
||||
edit =
|
||||
null
|
||||
repository.rebuildCache(it)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
SettingsEditDialog(repository, authentication, action) { edit = null }
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
licenses = false
|
||||
},
|
||||
title = { Text(stringResource(R.string.settings_licenses)) },
|
||||
text = { Text(text, modifier = Modifier.verticalScroll(rememberScrollState())) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
licenses = false
|
||||
}) { Text(stringResource(R.string.settings_done)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetting(title: String, status: ProviderStatus, open: () -> Unit) {
|
||||
Text(title)
|
||||
Text(
|
||||
stringResource(
|
||||
when {
|
||||
!status.supported -> R.string.settings_provider_unsupported
|
||||
!status.installed -> R.string.settings_provider_not_installed
|
||||
status.enabled -> R.string.settings_provider_enabled
|
||||
else -> R.string.settings_provider_disabled
|
||||
}
|
||||
)
|
||||
)
|
||||
TextButton(onClick = open) { Text(stringResource(R.string.settings_provider_open)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsEditDialog(
|
||||
repository: SettingsRepository,
|
||||
authentication: AuthenticationRepository,
|
||||
action: String,
|
||||
close: () -> Unit
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
var email by rememberProtectedInput(authentication.presentation)
|
||||
var submitted by remember { mutableStateOf(false) }
|
||||
var timeout by rememberProtectedInput(authentication.presentation)
|
||||
LaunchedEffect(action) {
|
||||
name = state.page?.gitName.orEmpty()
|
||||
email =
|
||||
state.page?.gitEmail.orEmpty()
|
||||
timeout = state.page?.timeout?.toString().orEmpty()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
when (action) {
|
||||
"git" -> R.string.settings_edit_git
|
||||
"timeout" -> R.string.settings_edit_timeout
|
||||
else -> R.string.settings_remove_token
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
when (action) {
|
||||
"git" -> {
|
||||
OutlinedTextField(name, {
|
||||
name = it
|
||||
}, label = {
|
||||
Text(stringResource(R.string.settings_git_name))
|
||||
}, modifier = Modifier.testTag("settings-git-name"))
|
||||
OutlinedTextField(email, {
|
||||
email = it
|
||||
}, label = {
|
||||
Text(stringResource(R.string.settings_git_email))
|
||||
}, modifier = Modifier.testTag("settings-git-email"))
|
||||
}
|
||||
"timeout" -> OutlinedTextField(timeout, {
|
||||
timeout = it
|
||||
}, label = {
|
||||
Text(stringResource(R.string.settings_timeout_seconds))
|
||||
}, modifier = Modifier.testTag("settings-timeout"))
|
||||
else -> Text(stringResource(R.string.settings_remove_detail))
|
||||
}
|
||||
state.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.testTag("settings-dialog-error")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
submitted = true
|
||||
when (action) {
|
||||
"git" -> repository.setGitIdentity(name, email)
|
||||
"timeout" -> repository.setTimeout(timeout)
|
||||
else -> repository.removeToken()
|
||||
}
|
||||
}) { Text(stringResource(R.string.settings_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = close) { Text(stringResource(R.string.auth_cancel)) }
|
||||
}
|
||||
)
|
||||
LaunchedEffect(state.notice, state.busy) {
|
||||
if (submitted && state.notice != null &&
|
||||
!state.busy
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyTransferDialog(
|
||||
repository: SettingsRepository,
|
||||
authentication: AuthenticationRepository
|
||||
) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
var fingerprint by remember {
|
||||
mutableStateOf(state.transfer.keys.firstOrNull()?.fingerprint.orEmpty())
|
||||
}
|
||||
var privateKey by remember { mutableStateOf(false) }
|
||||
var makeDefault by remember { mutableStateOf(false) }
|
||||
var overwrite by remember { mutableStateOf(false) }
|
||||
var frame by remember { mutableStateOf(0) }
|
||||
var passphrase by rememberProtectedInput(authentication.presentation)
|
||||
var payload by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
properties = DialogProperties(
|
||||
securePolicy = SecureFlagPolicy.SecureOn,
|
||||
usePlatformDefaultWidth = false
|
||||
),
|
||||
onDismissRequest = repository::closeTransfer,
|
||||
title = {
|
||||
Text(stringResource(R.string.settings_key_transfer))
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (state.busy) CircularProgressIndicator()
|
||||
state.transfer.keys.forEach { key ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectable(
|
||||
selected = fingerprint == key.fingerprint,
|
||||
enabled = !state.busy,
|
||||
role = Role.RadioButton,
|
||||
onClick = { fingerprint = key.fingerprint }
|
||||
)
|
||||
) {
|
||||
RadioButton(selected = fingerprint == key.fingerprint, onClick = null)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(key.title)
|
||||
Text(key.fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
enabled =
|
||||
!state.busy &&
|
||||
state.transfer.keys.any {
|
||||
it.fingerprint == fingerprint && it.privateKey
|
||||
},
|
||||
onClick = { repository.setDefaultKey(fingerprint) }
|
||||
) { Text(stringResource(R.string.settings_default_key_select)) }
|
||||
Text(stringResource(R.string.settings_private_warning))
|
||||
SettingsToggle(stringResource(R.string.settings_private_export), privateKey) {
|
||||
privateKey =
|
||||
it
|
||||
}
|
||||
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
|
||||
passphrase = it
|
||||
}
|
||||
TextButton(enabled = !state.busy && fingerprint.isNotEmpty(), onClick = {
|
||||
repository.exportKey(fingerprint, privateKey, passphrase)
|
||||
passphrase = ""
|
||||
frame =
|
||||
0
|
||||
}) { Text(stringResource(R.string.settings_export_confirm)) }
|
||||
state.transfer.frames.getOrNull(frame)?.let { qr ->
|
||||
val description =
|
||||
stringResource(
|
||||
R.string.settings_qr_frame,
|
||||
qr.sequence.toString(),
|
||||
qr.total.toString()
|
||||
)
|
||||
Text(description)
|
||||
Canvas(
|
||||
Modifier.fillMaxWidth().height(280.dp).semantics {
|
||||
contentDescription =
|
||||
description
|
||||
}
|
||||
) {
|
||||
drawRect(Color.White)
|
||||
val width = qr.width.toInt()
|
||||
val scale = minOf(size.width, size.height) / (width + 8)
|
||||
val x = (size.width - scale * width) / 2
|
||||
val y = (size.height - scale * width) / 2
|
||||
for (row in 0 until width) {
|
||||
for (column in 0 until width) {
|
||||
if (qr.modules[
|
||||
row *
|
||||
width +
|
||||
column
|
||||
].toInt() ==
|
||||
1
|
||||
) {
|
||||
drawRect(
|
||||
Color.Black,
|
||||
Offset(x + column * scale, y + row * scale),
|
||||
Size(scale, scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FlowRow {
|
||||
TextButton(enabled = frame > 0, onClick = {
|
||||
frame -= 1
|
||||
}) { Text(stringResource(R.string.settings_previous)) }
|
||||
TextButton(enabled = frame < state.transfer.frames.lastIndex, onClick = {
|
||||
frame +=
|
||||
1
|
||||
}) { Text(stringResource(R.string.settings_next)) }
|
||||
}
|
||||
}
|
||||
TextButton(enabled = !state.busy, onClick = repository::chooseQrImages) {
|
||||
Text(stringResource(R.string.settings_qr_images))
|
||||
}
|
||||
Text(stringResource(R.string.settings_qr_images_detail))
|
||||
SecretField(
|
||||
stringResource(R.string.settings_qr_payload),
|
||||
payload,
|
||||
singleLine = false
|
||||
) {
|
||||
payload =
|
||||
it
|
||||
}
|
||||
TextButton(enabled = !state.busy && payload.isNotEmpty(), onClick = {
|
||||
repository.addQrPayload(payload)
|
||||
payload =
|
||||
""
|
||||
}) { Text(stringResource(R.string.settings_qr_add)) }
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_qr_progress,
|
||||
state.transfer.received.toString(),
|
||||
state.transfer.total.toString()
|
||||
)
|
||||
)
|
||||
state.transfer.ready?.let {
|
||||
Text(it.title)
|
||||
Text(it.fingerprint)
|
||||
SettingsToggle(stringResource(R.string.settings_make_default), makeDefault) {
|
||||
makeDefault =
|
||||
it
|
||||
}
|
||||
SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) {
|
||||
overwrite =
|
||||
it
|
||||
}
|
||||
TextButton(enabled = !state.busy, onClick = {
|
||||
repository.importKey(passphrase, makeDefault, overwrite)
|
||||
passphrase =
|
||||
""
|
||||
}) { Text(stringResource(R.string.settings_import_confirm)) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = repository::closeTransfer) {
|
||||
Text(stringResource(R.string.settings_done))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
value = checked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = change
|
||||
)
|
||||
) {
|
||||
Checkbox(checked, onCheckedChange = null)
|
||||
Text(label, modifier = Modifier.padding(vertical = 12.dp))
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ private fun ShellDestination(
|
||||
(snapshot.state == ShellState.Empty && setup.configured)
|
||||
)
|
||||
when {
|
||||
!snapshot.fixture && route == ShellRoute.Preferences -> SettingsScreen(viewModel, setupForm)
|
||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
||||
(
|
||||
(snapshot.state == ShellState.Empty && !setup.configured) ||
|
||||
@@ -168,18 +169,6 @@ private fun ShellDestination(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (ready && route == ShellRoute.Preferences) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
setupForm.clearSecrets()
|
||||
viewModel.openAppearance()
|
||||
},
|
||||
modifier = Modifier.testTag("shell-open-appearance")
|
||||
) {
|
||||
Text(stringResource(R.string.shell_appearance))
|
||||
}
|
||||
AuthenticationControls(viewModel.authentication)
|
||||
}
|
||||
OnboardingScreen(viewModel.onboarding, setupForm, scroll = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.SearchRepository
|
||||
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellState
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
@@ -28,6 +29,7 @@ class ShellViewModel @Inject constructor(
|
||||
val search: SearchRepository,
|
||||
val authentication: AuthenticationRepository,
|
||||
val editor: EditorRepository,
|
||||
val settings: SettingsRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
@@ -110,7 +112,10 @@ class ShellViewModel @Inject constructor(
|
||||
ShellTab.Home -> home.leave()
|
||||
ShellTab.Passwords -> passwords.leave()
|
||||
ShellTab.Search -> search.cancel()
|
||||
ShellTab.Preferences -> if (authentication.state.value.busy) authentication.cancel()
|
||||
ShellTab.Preferences -> {
|
||||
settings.cancel()
|
||||
if (authentication.state.value.busy) authentication.cancel()
|
||||
}
|
||||
ShellTab.Totp -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 rfc1437
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -148,4 +148,64 @@
|
||||
<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>
|
||||
<string name="settings_title">Preferences</string>
|
||||
<string name="settings_default_key_select">Use selected private key as default</string>
|
||||
<string name="settings_save">Save</string>
|
||||
<string name="settings_search">Search settings</string>
|
||||
<string name="settings_reload">Reload settings</string>
|
||||
<string name="settings_store">Password store, server, token and GPG keys</string>
|
||||
<string name="settings_git">Git commit author name and email</string>
|
||||
<string name="settings_auth">Authentication, inactivity timeout, biometrics and appearance</string>
|
||||
<string name="settings_cache">TOTP discovery cache</string>
|
||||
<string name="settings_providers">Autofill and Credential Manager</string>
|
||||
<string name="settings_recovery">Recovery and offline use</string>
|
||||
<string name="settings_about">Version, licenses, privacy and support</string>
|
||||
<string name="settings_no_token">No application token stored</string>
|
||||
<string name="settings_token_hidden">Existing tokens and passphrases are never displayed. Replacement stores only the submitted value in protected device storage.</string>
|
||||
<string name="settings_remote_token">HTTPS setup and token replacement</string>
|
||||
<string name="settings_remove_token">Remove application token</string>
|
||||
<string name="settings_key_transfer">Import or export GPG QR codes</string>
|
||||
<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>
|
||||
<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>
|
||||
<string name="settings_autofill">Autofill password provider</string>
|
||||
<string name="settings_credentials">Credential Manager password provider</string>
|
||||
<string name="settings_recovery_key">If biometric enrollment changes or a protected passphrase is invalidated, unlock with the GPG passphrase and enable biometric unlock again. A missing device Keystore binding requires recovery from your original repository and GPG key; encrypted restored data is never silently accepted or deleted.</string>
|
||||
<string name="settings_recovery_store">If the repository or GPG key is missing, keep existing data and restore the original encrypted repository and private key. Reload after recovery to see the storage-provided status.</string>
|
||||
<string name="settings_recovery_cache">A damaged or outdated TOTP cache can be rebuilt after unlocking. The cache contains discovery metadata, never OTP seeds or codes.</string>
|
||||
<string name="settings_offline">Offline use keeps local reading, editing and Git history available. HTTPS sync needs a network and an application token; failed network operations keep local data intact.</string>
|
||||
<string name="settings_version">IronStorage %1$s</string>
|
||||
<string name="settings_privacy">Passwords and private keys stay in protected app storage. No analytics or advertising are included. HTTPS synchronization connects only to the configured server. Secrets are hidden from screenshots, saved form state, logs and personalized keyboard learning.</string>
|
||||
<string name="settings_licenses">MIT license</string>
|
||||
<string name="settings_support">Support and issue tracker</string>
|
||||
<string name="settings_dependency_licenses">Dependency license inventory</string>
|
||||
<string name="settings_done">Done</string>
|
||||
<string name="settings_provider_unsupported">Not supported on this Android version or device</string>
|
||||
<string name="settings_provider_not_installed">The IronStorage provider service is not included in this build</string>
|
||||
<string name="settings_provider_enabled">Enabled for IronStorage</string>
|
||||
<string name="settings_provider_disabled">Available for IronStorage, currently disabled</string>
|
||||
<string name="settings_provider_open">Open system provider settings</string>
|
||||
<string name="settings_rebuild_detail">Enter the GPG passphrase to inspect encrypted entries and rebuild the discovery cache. No OTP seed is stored in the cache.</string>
|
||||
<string name="settings_git_name">Git author name</string>
|
||||
<string name="settings_git_email">Git author email</string>
|
||||
<string name="settings_timeout_seconds">Timeout in seconds (1–86400)</string>
|
||||
<string name="settings_remove_detail">Remove the protected HTTPS application token? Local data and commits remain available. Authenticated synchronization will require a replacement token.</string>
|
||||
<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_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>
|
||||
<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>
|
||||
</resources>
|
||||
Reference in new issue
Block a user