Implement adaptive Android shell and safe navigation (#102)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 25s

This commit is contained in:
hugo committed 2026-10-09 20:33:31 +02:00
1 parent c7703d8efd
commit d22d71eb5e
32 files changed
+2194 -104

No files matched your search

@@ -0,0 +1,25 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.Appearance
import de.rfc1437.ironstorage.core.AuthenticationRepository
@Composable
fun ApplicationTheme(authentication: AuthenticationRepository, content: @Composable () -> Unit) {
val state by authentication.state.collectAsStateWithLifecycle()
val dark = when (state.appearance) {
Appearance.System -> isSystemInDarkTheme()
Appearance.Light -> false
Appearance.Dark -> true
}
MaterialTheme(
colorScheme = if (dark) darkColorScheme() else lightColorScheme(),
content = content
)
}
@@ -46,13 +46,17 @@ import de.rfc1437.ironstorage.core.ShellIcon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(repository: HomeRepository, modifier: Modifier = Modifier) {
fun HomeScreen(
repository: HomeRepository,
modifier: Modifier = Modifier,
leaveOnDispose: Boolean = true
) {
val state by repository.state.collectAsStateWithLifecycle()
var committing by remember { mutableStateOf(false) }
var message by remember { mutableStateOf("") }
DisposableEffect(repository) {
repository.enter()
onDispose { repository.leave() }
onDispose { if (leaveOnDispose) repository.leave() }
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
committing = false
@@ -54,7 +54,8 @@ class SetupFormViewModel @Inject constructor() : ViewModel() {
fun OnboardingScreen(
repository: OnboardingRepository,
secrets: SetupFormViewModel,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
scroll: Boolean = true
) {
ProtectedInputSurface()
val state by repository.setup.collectAsStateWithLifecycle()
@@ -77,7 +78,9 @@ fun OnboardingScreen(
else -> step
}
Column(
modifier.widthIn(max = 640.dp).fillMaxWidth().verticalScroll(rememberScrollState()),
modifier.widthIn(max = 640.dp).fillMaxWidth().then(
if (scroll) Modifier.verticalScroll(rememberScrollState()) else Modifier
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
@@ -56,11 +56,12 @@ import de.rfc1437.ironstorage.core.ShellIcon
fun PasswordScreen(
repository: PasswordRepository,
authentication: AuthenticationRepository,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
leaveOnDispose: Boolean = true
) {
DisposableEffect(repository, authentication) {
onDispose {
if (authentication.state.value.unlocked) repository.lock()
if (leaveOnDispose) repository.leave()
}
}
val state by repository.state.collectAsStateWithLifecycle()
@@ -87,7 +88,7 @@ fun PasswordScreen(
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
manual = false
requested = false
repository.lock()
repository.leave()
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (!state.loading && state.revision.isNotEmpty()) repository.refresh()
@@ -39,14 +39,15 @@ import de.rfc1437.ironstorage.core.SearchRepository
fun SearchScreen(
repository: SearchRepository,
open: (PasswordRow) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
leaveOnDispose: Boolean = true
) {
val state by repository.state.collectAsStateWithLifecycle()
val keyboard = LocalSoftwareKeyboardController.current
val focus = LocalFocusManager.current
DisposableEffect(repository) {
repository.refresh()
onDispose { repository.cancel() }
onDispose { if (leaveOnDispose) repository.cancel() }
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.refresh() }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.cancel() }
@@ -1,121 +1,303 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
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.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
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.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import de.rfc1437.ironstorage.core.Appearance
import de.rfc1437.ironstorage.core.NativeFailure
import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.core.ShellSnapshot
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
private enum class ShellRoute(val tab: ShellTab?) {
Home(ShellTab.Home),
Passwords(ShellTab.Passwords),
Search(ShellTab.Search),
Totp(ShellTab.Totp),
Preferences(ShellTab.Preferences),
Appearance(null)
}
@Composable
fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
val snapshot by viewModel.snapshot.collectAsStateWithLifecycle()
Scaffold(
bottomBar = {
NavigationBar {
snapshot.pages.forEach { page ->
val tab = page.tab
NavigationBarItem(
selected = snapshot.selectedTab == tab,
onClick = { viewModel.selectTab(tab) },
icon = {
Icon(
painterResource(navigationIcon(page.icon)),
contentDescription = null
val appearancePage by viewModel.appearancePage.collectAsStateWithLifecycle()
fun selectTab(tab: ShellTab) {
if (tab != snapshot.selectedTab) setupForm.clearSecrets()
viewModel.selectTab(tab)
}
val route = when (snapshot.selectedTab) {
ShellTab.Home -> ShellRoute.Home
ShellTab.Passwords -> ShellRoute.Passwords
ShellTab.Search -> ShellRoute.Search
ShellTab.Totp -> ShellRoute.Totp
ShellTab.Preferences -> ShellRoute.Preferences
}
val backStack = if (route == ShellRoute.Preferences && appearancePage && !snapshot.fixture) {
listOf(route, ShellRoute.Appearance)
} else {
listOf(route)
}
// Actual window bounds, rather than device type, also handle desktop and split-screen resize.
BoxWithConstraints(Modifier.fillMaxSize()) {
val rail = maxWidth >= 600.dp
val largeFont = LocalDensity.current.fontScale >= 1.6f
Scaffold(
bottomBar = {
if (!rail) {
NavigationBar(Modifier.testTag("shell-bottom-navigation")) {
snapshot.pages.forEach { page ->
NavigationBarItem(
modifier = Modifier.testTag("shell-tab-${page.tab.name}"),
selected = snapshot.selectedTab == page.tab,
onClick = { selectTab(page.tab) },
icon = {
Icon(
painterResource(navigationIcon(page.icon)),
if (largeFont) page.title else null
)
},
label = if (largeFont) null else ({ Text(page.title) })
)
},
label = { Text(page.title) }
)
}
}
}
}
) { padding ->
Row(Modifier.fillMaxSize().padding(padding)) {
if (rail) {
NavigationRail(
Modifier.testTag(
"shell-navigation-rail"
).verticalScroll(rememberScrollState())
) {
snapshot.pages.forEach { page ->
NavigationRailItem(
modifier = Modifier.testTag("shell-tab-${page.tab.name}"),
selected = snapshot.selectedTab == page.tab,
onClick = { selectTab(page.tab) },
icon = { Icon(painterResource(navigationIcon(page.icon)), null) },
label = { Text(page.title) }
)
}
}
}
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(
1f
).fillMaxSize().padding(16.dp).testTag("shell-content"),
// Screen inputs are never saved by Navigation 3.
entryDecorators = emptyList(),
onBack = viewModel::backAppearance,
entryProvider = { key ->
NavEntry(key) {
if (key == ShellRoute.Appearance) {
AppearanceScreen(viewModel)
} else {
ShellDestination(key, snapshot, viewModel, setupForm)
}
}
}
)
}
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val ready = !snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
val browsing = ready &&
snapshot.selectedTab == ShellTab.Passwords
val searching = ready && snapshot.selectedTab == ShellTab.Search
val home = ready && snapshot.selectedTab == ShellTab.Home
if (!browsing && !home && !searching) {
}
}
@Composable
private fun ShellDestination(
route: ShellRoute,
snapshot: ShellSnapshot,
viewModel: ShellViewModel,
setupForm: SetupFormViewModel
) {
val ready =
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Ready || snapshot.state == ShellState.Locked)
when {
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || route == ShellRoute.Preferences) -> {
Column(
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)
}
}
ready && route == ShellRoute.Passwords -> {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(
viewModel.passwords,
viewModel.authentication,
Modifier.fillMaxSize(),
leaveOnDispose = false
)
}
ready && route == ShellRoute.Search -> SearchScreen(
viewModel.search,
viewModel::openSearch,
Modifier.fillMaxSize(),
leaveOnDispose = false
)
ready && route == ShellRoute.Home -> HomeScreen(
viewModel.home,
Modifier.fillMaxSize(),
leaveOnDispose = false
)
else -> {
val page = snapshot.pages.firstOrNull { it.tab == route.tab }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
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) {
stringResource(R.string.native_failure_title)
} else {
page?.stateTitle ?: stringResource(R.string.scaffold_title)
}
val detail = if (nativeFailure == null) {
page?.detail ?: stringResource(R.string.scaffold_detail)
} else {
stringResource(
when (nativeFailure) {
NativeFailure.UnsupportedAbi -> R.string.native_unsupported_device
NativeFailure.MissingLibrary,
NativeFailure.ContractMismatch,
NativeFailure.ChecksumMismatch,
NativeFailure.LoadFailure -> R.string.native_update_required
NativeFailure.RustFailure,
NativeFailure.MainThreadCall -> R.string.native_operation_failed
}
)
}
if (!snapshot.fixture && snapshot.nativeFailure == null &&
snapshot.storageRecovery == null &&
(snapshot.state == ShellState.Empty || snapshot.selectedTab == ShellTab.Preferences)
) {
if (ready && snapshot.selectedTab == ShellTab.Preferences) {
AuthenticationControls(viewModel.authentication)
}
OnboardingScreen(viewModel.onboarding, setupForm)
} else if (browsing) {
LaunchedEffect(viewModel) { viewModel.openPasswords() }
PasswordScreen(viewModel.passwords, viewModel.authentication, Modifier.weight(1f))
} else if (searching) {
SearchScreen(viewModel.search, viewModel::openSearch, Modifier.weight(1f))
} else if (home) {
HomeScreen(viewModel.home, Modifier.weight(1f))
} else {
val failure = snapshot.nativeFailure
Text(
title,
modifier = Modifier.semantics { heading() },
if (failure !=
null
) {
stringResource(R.string.native_failure_title)
} else {
page?.stateTitle
?: stringResource(R.string.scaffold_title)
},
modifier = Modifier.semantics {
heading()
liveRegion = LiveRegionMode.Polite
},
style = MaterialTheme.typography.headlineSmall
)
Text(detail)
Text(
if (failure ==
null
) {
page?.detail ?: stringResource(R.string.scaffold_detail)
} else {
stringResource(
when (failure) {
NativeFailure.UnsupportedAbi -> R.string.native_unsupported_device
NativeFailure.MissingLibrary,
NativeFailure.ContractMismatch,
NativeFailure.ChecksumMismatch,
NativeFailure.LoadFailure -> R.string.native_update_required
NativeFailure.RustFailure,
NativeFailure.MainThreadCall -> R.string.native_operation_failed
}
)
}
)
if (snapshot.state == ShellState.Loading) CircularProgressIndicator()
if (!snapshot.fixture) {
TextButton(onClick = viewModel::refreshShell) {
Text(stringResource(R.string.shell_refresh))
}
}
}
}
}
}
@Composable
private fun AppearanceScreen(viewModel: ShellViewModel) {
val state by viewModel.authentication.state.collectAsStateWithLifecycle()
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).selectableGroup(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
TextButton(onClick = viewModel::backAppearance) {
Text(stringResource(R.string.password_back))
}
Text(
stringResource(R.string.shell_appearance),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics {
heading()
}
)
Appearance.entries.forEach { appearance ->
val label = stringResource(
when (appearance) {
Appearance.System -> R.string.shell_appearance_system
Appearance.Light -> R.string.shell_appearance_light
Appearance.Dark -> R.string.shell_appearance_dark
}
)
Row(
Modifier.fillMaxWidth().selectable(
selected = state.appearance == appearance,
enabled = !state.busy,
role = Role.RadioButton,
onClick = { viewModel.authentication.setAppearance(appearance) }
).padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label)
RadioButton(
selected = state.appearance == appearance,
enabled = !state.busy,
onClick = null
)
}
}
state.failure?.let {
Text(it.title + "\n" + it.detail, color = MaterialTheme.colorScheme.error)
}
}
}
private fun navigationIcon(icon: ShellIcon): Int = when (icon) {
ShellIcon.Home -> R.drawable.ic_home
ShellIcon.Key -> R.drawable.ic_key
@@ -11,8 +11,11 @@ import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
@@ -26,8 +29,29 @@ class ShellViewModel @Inject constructor(
private val saved: SavedStateHandle
) : ViewModel() {
val snapshot = repository.snapshot
private val mutableAppearancePage =
MutableStateFlow(saved.get<Boolean>("shell.appearance") ?: false)
val appearancePage = mutableAppearancePage.asStateFlow()
fun openAppearance() {
onboarding.cancel()
if (authentication.state.value.busy) authentication.cancel()
saved["shell.appearance"] = true
mutableAppearancePage.value = true
}
fun backAppearance() {
saved["shell.appearance"] = false
mutableAppearancePage.value = false
}
init {
val restored = saved.get<String>("shell.tab")?.let { name ->
ShellTab.entries.firstOrNull { it.name == name }
}
if (restored != null) repository.selectTab(restored)
viewModelScope.launch {
snapshot.collect { saved["shell.tab"] = it.selectedTab.name }
}
viewModelScope.launch {
passwords.state.collect { value ->
if (!value.loading && value.error == null && value.revision.isNotEmpty()) {
@@ -41,7 +65,7 @@ class ShellViewModel @Inject constructor(
private var passwordsRestored = false
fun openSearch(row: PasswordRow) {
passwords.openSearch(row.id, row.revision)
repository.selectTab(ShellTab.Passwords)
selectTab(ShellTab.Passwords)
}
fun openPasswords() {
if (!passwordsRestored) {
@@ -58,10 +82,28 @@ class ShellViewModel @Inject constructor(
passwords.refresh()
}
}
fun refreshShell() = repository.refresh()
fun selectTab(tab: ShellTab) {
if (tab != snapshot.value.selectedTab) leaveDestination()
saved["shell.tab"] = tab.name
repository.selectTab(tab)
}
private fun leaveDestination() {
val current = snapshot.value
if (current.fixture) return
if (current.state == ShellState.Empty || current.selectedTab == ShellTab.Preferences) {
onboarding.cancel()
}
when (current.selectedTab) {
ShellTab.Home -> home.leave()
ShellTab.Passwords -> passwords.leave()
ShellTab.Search -> search.cancel()
ShellTab.Preferences -> if (authentication.state.value.busy) authentication.cancel()
ShellTab.Totp -> Unit
}
}
override fun onCleared() {
leaveDestination()
passwords.lock()
}
}
@@ -113,4 +113,9 @@
<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>
<string name="shell_appearance_system">System default</string>
<string name="shell_appearance_light">Light</string>
<string name="shell_appearance_dark">Dark</string>
<string name="shell_refresh">Reload state</string>
</resources>