Implement Rust-owned adaptive Android password navigation (#96)
This commit is contained in:
1 parent
36e5f55283
commit
8c9341f214
26 files changed
+1553
-16
No files matched your search
@@ -0,0 +1,298 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun PasswordScreen(repository: PasswordRepository, modifier: Modifier = Modifier) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
var actions by remember { mutableStateOf(false) }
|
||||
var passphrase by remember { mutableStateOf("") }
|
||||
val entry = state.selected?.takeIf { it.kind == PasswordKind.Entry }
|
||||
LaunchedEffect(entry?.id) {
|
||||
passphrase = ""
|
||||
navigator.navigateTo(
|
||||
if (entry ==
|
||||
null
|
||||
) {
|
||||
ListDetailPaneScaffoldRole.List
|
||||
} else {
|
||||
ListDetailPaneScaffoldRole.Detail
|
||||
},
|
||||
entry?.id
|
||||
)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
passphrase = ""
|
||||
repository.lock()
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
if (state.revision.isNotEmpty()) repository.refresh()
|
||||
}
|
||||
DisposableEffect(repository) { onDispose { repository.lock() } }
|
||||
BackHandler(state.selected != null || state.parent != null) {
|
||||
actions = false
|
||||
repository.back()
|
||||
}
|
||||
val actionLabel = stringResource(R.string.password_actions)
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
TextButton(
|
||||
enabled = !state.loading && (state.parent != null || state.selected != null),
|
||||
onClick = repository::back
|
||||
) {
|
||||
Text(stringResource(R.string.password_back))
|
||||
}
|
||||
TextButton(enabled = !state.loading, onClick = repository::refresh) {
|
||||
Text(stringResource(R.string.password_refresh))
|
||||
}
|
||||
}
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
modifier = Modifier.weight(1f),
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
state.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics { heading() }
|
||||
)
|
||||
Text(state.path.ifEmpty { stringResource(R.string.password_root) })
|
||||
if (state.loading) CircularProgressIndicator()
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (state.stale) Text(stringResource(R.string.password_changed))
|
||||
}
|
||||
}
|
||||
if (!state.loading && state.error == null && state.rows.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.password_empty),
|
||||
Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(state.rows, key = { it.id }) { row ->
|
||||
val rowActions =
|
||||
stringResource(R.string.password_actions_for, row.title)
|
||||
val kindLabel = stringResource(
|
||||
if (row.kind ==
|
||||
PasswordKind.Folder
|
||||
) {
|
||||
R.string.password_folder
|
||||
} else {
|
||||
R.string.password_entry
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(row.title) },
|
||||
supportingContent = { Text(row.detail) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(
|
||||
when (row.icon) {
|
||||
ShellIcon.Folder -> R.drawable.ic_folder
|
||||
ShellIcon.Key -> R.drawable.ic_key
|
||||
else -> error("Invalid Rust navigation icon")
|
||||
}
|
||||
),
|
||||
null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription =
|
||||
rowActions
|
||||
},
|
||||
enabled = !state.loading,
|
||||
onClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
) { Text(actionLabel) }
|
||||
},
|
||||
modifier = Modifier.combinedClickable(
|
||||
enabled = !state.loading,
|
||||
onClickLabel = stringResource(R.string.password_open),
|
||||
onLongClickLabel = rowActions,
|
||||
onClick = {
|
||||
actions = false
|
||||
repository.activate(row.id, state.revision)
|
||||
},
|
||||
onLongClick = {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
}
|
||||
).semantics {
|
||||
stateDescription = kindLabel
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(rowActions) {
|
||||
actions = true
|
||||
repository.inspect(row.id, state.revision)
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (entry == null) {
|
||||
item { Text(stringResource(R.string.password_choose)) }
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
entry.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
}
|
||||
)
|
||||
Text(entry.path)
|
||||
if (state.loading) CircularProgressIndicator()
|
||||
state.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
val sections = state.sections
|
||||
if (sections == null) {
|
||||
item {
|
||||
Text(entry.detail)
|
||||
OutlinedTextField(
|
||||
passphrase,
|
||||
{
|
||||
passphrase = it
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.setup_passphrase))
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = !state.loading
|
||||
)
|
||||
Button(enabled = !state.loading, onClick = {
|
||||
val submitted = passphrase
|
||||
passphrase =
|
||||
""
|
||||
repository.unlock(submitted)
|
||||
}) { Text(stringResource(R.string.password_unlock)) }
|
||||
}
|
||||
} 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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
AlertDialog(
|
||||
onDismissRequest = { actions = false },
|
||||
title = {
|
||||
Text(selected.title)
|
||||
},
|
||||
text = { Text(selected.detail + "\n" + selected.path) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
repository.activate(selected.id, state.revision)
|
||||
}) { Text(stringResource(R.string.password_open)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
actions = false
|
||||
}) { Text(stringResource(R.string.password_close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -52,7 +53,15 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge)
|
||||
val browsing = !snapshot.fixture && snapshot.nativeFailure == null &&
|
||||
snapshot.storageRecovery == null && snapshot.state != ShellState.Empty &&
|
||||
snapshot.selectedTab == ShellTab.Passwords
|
||||
if (!browsing) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
}
|
||||
val page = snapshot.pages.firstOrNull { it.tab == snapshot.selectedTab }
|
||||
val nativeFailure = snapshot.nativeFailure
|
||||
val title = if (nativeFailure != null) {
|
||||
@@ -80,6 +89,9 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
|
||||
) {
|
||||
OnboardingScreen(viewModel.onboarding, setupForm)
|
||||
} else if (browsing) {
|
||||
LaunchedEffect(viewModel) { viewModel.openPasswords() }
|
||||
PasswordScreen(viewModel.passwords, Modifier.weight(1f))
|
||||
} else {
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.ShellTab
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class ShellViewModel @Inject constructor(
|
||||
private val repository: ShellRepository,
|
||||
val onboarding: OnboardingRepository
|
||||
val onboarding: OnboardingRepository,
|
||||
val passwords: PasswordRepository,
|
||||
private val saved: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
val snapshot = repository.snapshot
|
||||
|
||||
fun selectTab(tab: ShellTab) = repository.selectTab(tab)
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
passwords.state.collect { value ->
|
||||
if (!value.loading && value.error == null && value.revision.isNotEmpty()) {
|
||||
saved["password.path"] = value.path
|
||||
saved["password.selection"] = value.selected?.id
|
||||
saved["password.revision"] = value.revision
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private var passwordsRestored = false
|
||||
fun openPasswords() {
|
||||
if (!passwordsRestored) {
|
||||
passwordsRestored = true
|
||||
passwords.restore(
|
||||
saved["password.path"] ?: "",
|
||||
saved["password.selection"],
|
||||
saved["password.revision"]
|
||||
)
|
||||
} else {
|
||||
passwords.refresh()
|
||||
}
|
||||
}
|
||||
fun selectTab(tab: ShellTab) {
|
||||
if (tab !=
|
||||
ShellTab.Passwords
|
||||
) {
|
||||
passwords.lock()
|
||||
}
|
||||
repository.selectTab(tab)
|
||||
}
|
||||
override fun onCleared() {
|
||||
passwords.lock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000" android:pathData="M10,4H2v16h20V6H12z" />
|
||||
</vector>
|
||||
@@ -30,4 +30,18 @@
|
||||
<string name="setup_replace_token">Replace application token</string>
|
||||
<string name="setup_back">Back to setup choices</string>
|
||||
<string name="setup_retry">Reload setup state</string>
|
||||
<string name="password_back">Back</string>
|
||||
<string name="password_refresh">Refresh</string>
|
||||
<string name="password_root">Password Store</string>
|
||||
<string name="password_changed">The repository changed. Stale selections were cleared.</string>
|
||||
<string name="password_empty">This folder is empty.</string>
|
||||
<string name="password_choose">Select an entry to view it.</string>
|
||||
<string name="password_actions">Actions</string>
|
||||
<string name="password_actions_for">Actions for %1$s</string>
|
||||
<string name="password_folder">Folder</string>
|
||||
<string name="password_entry">Locked password entry</string>
|
||||
<string name="password_open">Open</string>
|
||||
<string name="password_close">Close</string>
|
||||
<string name="password_unlock">Unlock</string>
|
||||
<string name="password_lock">Lock</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user