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,177 @@
package de.rfc1437.ironstorage
import android.content.pm.ActivityInfo
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModelProvider
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellFixtures
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellSnapshot
import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdaptiveShellTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun independentPreferencesStackAndSafeRecreationUseCompactAndExpandedNavigation() {
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val repository = Fixture()
val saved = SavedStateHandle()
var setupCancellations = 0
val onboarding = object : OnboardingRepository by original.onboarding {
override fun cancel() {
setupCancellations += 1
original.onboarding.cancel()
}
}
val model =
ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
saved
)
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_PORTRAIT
}
lateinit var inputMode: InputModeManager
fun show(viewModel: ShellViewModel, largeFont: Boolean = false) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
inputMode = LocalInputModeManager.current
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, if (largeFont) 2f else 1f)
) {
ApplicationTheme(original.authentication) { ShellScreen(viewModel, setup) }
}
}
}
}
show(model)
compose.onNodeWithTag("shell-bottom-navigation").assertIsDisplayed()
compose.runOnIdle { assertTrue(inputMode.requestInputMode(InputMode.Keyboard)) }
compose.onNodeWithTag("shell-tab-Preferences")
.assertHasClickAction().assertHeightIsAtLeast(48.dp)
.performSemanticsAction(SemanticsActions.RequestFocus) { assertTrue(it()) }
.performKeyInput { pressKey(Key.Enter) }
compose.runOnIdle {
setup.passphrase = "public fixture passphrase"
setup.armor = "public fixture armor"
setup.token = "public fixture token"
}
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("", setup.passphrase)
assertEquals("", setup.armor)
assertEquals("", setup.token)
assertEquals(1, setupCancellations)
}
compose.onNodeWithText("System default").assertIsDisplayed()
compose.runOnIdle { setup.token = "public fixture token" }
compose.onNodeWithTag("shell-tab-Search").performClick()
compose.runOnIdle {
assertEquals("", setup.token)
assertEquals(2, setupCancellations)
}
compose.onNodeWithTag("shell-tab-Preferences").performClick()
compose.onNodeWithText("System default").assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
compose.onNodeWithTag("shell-open-appearance").performClick()
compose.runOnIdle {
assertEquals("Preferences", saved.get<String>("shell.tab"))
assertEquals(true, saved.get<Boolean>("shell.appearance"))
assertTrue(
saved.keys().all {
saved.get<Any>(it) is String ||
saved.get<Any>(it) is Boolean
}
)
assertFalse(original.authentication.state.value.unlocked)
}
// A new ViewModel receives only primitive safe navigation values, as after process death.
val restored =
ShellViewModel(
repository,
onboarding,
original.passwords,
original.home,
original.search,
original.authentication,
SavedStateHandle(
saved.keys().associateWith {
saved.get<Any>(it)
}
)
)
show(restored)
compose.onNodeWithText("System default").assertIsDisplayed()
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
compose.activity.runOnUiThread {
compose.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
compose.waitUntil(10000) {
compose.activity.resources.configuration.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
}
show(restored, largeFont = true)
compose.onNodeWithTag("shell-navigation-rail").assertIsDisplayed()
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
compose.onNodeWithText("Dark").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Back").performScrollTo().performClick()
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
}
private class Fixture : ShellRepository {
override val snapshot = MutableStateFlow(
ShellSnapshot(
ShellState.Locked,
ShellFixtures.all.getValue(ShellState.Locked)
)
)
override fun selectTab(tab: ShellTab) {
snapshot.value =
snapshot.value.copy(selectedTab = tab)
}
}
}
@@ -101,6 +101,9 @@ class AuthenticationScreenTest {
var unlocks = 0
var disabled = 0
var cancelled = 0
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun enroll(passphrase: String) {
submitted = passphrase
@@ -192,6 +192,9 @@ class EntryViewerTest {
}
}
override val state = lease
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun unlock() = Unit
override fun enroll(passphrase: String) = Unit
@@ -184,6 +184,9 @@ class PasswordScreenTest {
}
}
override val state = lease
override fun setAppearance(appearance: de.rfc1437.ironstorage.core.Appearance) {
state.value = state.value.copy(appearance = appearance)
}
override fun reload() = Unit
override fun unlock() = error("Unexpected direct unlock")
override fun enroll(passphrase: String) = error("Unexpected enrollment")
@@ -13,13 +13,20 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.AndroidEntryPoint
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.ProtectedContent
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellViewModel
@@ -132,8 +139,17 @@ class MainActivity : FragmentActivity() {
enableEdgeToEdge()
setContent {
ProtectedContent(authentication.presentation) {
MaterialTheme {
VariantContent(viewModel, repository, setupForm)
ApplicationTheme(authentication) {
val lightBars = MaterialTheme.colorScheme.background.luminance() > 0.5f
SideEffect {
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = lightBars
isAppearanceLightNavigationBars = lightBars
}
}
Surface(Modifier.fillMaxSize()) {
VariantContent(viewModel, repository, setupForm)
}
}
}
}
@@ -59,6 +59,26 @@ class NativeEntryViewerTest {
val key = setup.generate("Viewer fixture", password).selectedKey
setup.setupLocal(requireNotNull(key), password)
}
for (appearance in Appearance.entries) {
withContext(Dispatchers.Main.immediate) { authentication.setAppearance(appearance) }
withTimeout(30000) {
authentication.state.first {
!it.busy &&
it.appearance == appearance
}
}
assertEquals(appearance, authentication.state.value.appearance)
assertFalse(authentication.state.value.unlocked)
}
val restoredAppearance = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
withContext(Dispatchers.Main.immediate) { restoredAppearance.reload() }
withTimeout(30000) {
restoredAppearance.state.first {
!it.busy &&
it.appearance == Appearance.Dark
}
}
assertFalse(restoredAppearance.state.value.unlocked)
authentication.authenticated { auth ->
val draft = auth.beginCreateEntry("", "viewer", password)
val first = draft.page.fields.first()
@@ -2,6 +2,8 @@ package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class Appearance { System, Light, Dark }
enum class AuthenticationFailureKind {
PassphraseRequired,
InvalidPassphrase,
@@ -24,11 +26,13 @@ data class AuthenticationState(
val biometricEnabled: Boolean = false,
val remainingSeconds: ULong = 0u,
val busy: Boolean = false,
val failure: AuthenticationFailure? = null
val failure: AuthenticationFailure? = null,
val appearance: Appearance = Appearance.System
)
interface AuthenticationRepository {
val presentation: ProtectedPresentation
val state: StateFlow<AuthenticationState>
fun setAppearance(appearance: Appearance)
fun reload()
fun unlock()
fun enroll(passphrase: String)
@@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core
import android.os.SystemClock
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAppearance
import de.rfc1437.ironstorage.bridge.generated.MobileAuthentication
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
@@ -48,7 +49,7 @@ class NativeAuthenticationRepository(
try {
val value = withContext(io) { current?.state() }
if (ticket == sequence.get() && value != null) {
publish(value.toFacade())
publish(value.toFacade().copy(appearance = mutable.value.appearance))
}
} catch (_: MobileAuthenticationFfiException) {
if (ticket == sequence.get()) lock()
@@ -83,14 +84,19 @@ class NativeAuthenticationRepository(
}
result
}
val value = withContext(io) { current!!.state() }
if (ticket == sequence.get()) publish(value.toFacade())
val value = withContext(io) {
current!!.state().toFacade().copy(
appearance = current!!.mobileAppearance().toFacade()
)
}
if (ticket == sequence.get()) publish(value)
result
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket == sequence.get()) {
val value = withContext(io) { current?.state() }
publish(
(value?.toFacade() ?: AuthenticationState()).copy(
appearance = mutable.value.appearance,
failure = error.toFacade()
)
)
@@ -110,6 +116,15 @@ class NativeAuthenticationRepository(
}
}
}
override fun setAppearance(appearance: Appearance) = run {
it.setMobileAppearance(
when (appearance) {
Appearance.System -> MobileAppearance.SYSTEM
Appearance.Light -> MobileAppearance.LIGHT
Appearance.Dark -> MobileAppearance.DARK
}
)
}
override fun reload() = run { it.state() }
override fun unlock() = run { it.restoreBiometricLease() }
override fun enroll(passphrase: String) = run { it.enrollBiometricUnlock(passphrase) }
@@ -137,6 +152,7 @@ class NativeAuthenticationRepository(
private fun unavailable() {
publish(
AuthenticationState(
appearance = mutable.value.appearance,
failure = AuthenticationFailure(
AuthenticationFailureKind.Configuration,
"Authentication is unavailable",
@@ -243,3 +259,9 @@ private fun MobileAuthenticationFfiException.Failed.toFacade() = AuthenticationF
title,
detail
)
private fun MobileAppearance.toFacade(): Appearance = when (this) {
MobileAppearance.SYSTEM -> Appearance.System
MobileAppearance.LIGHT -> Appearance.Light
MobileAppearance.DARK -> Appearance.Dark
}
@@ -33,12 +33,14 @@ class NativeFixtureShellRepository internal constructor(
override val snapshot: StateFlow<ShellSnapshot> = mutableSnapshot
private val revision = AtomicLong()
private var job: Job? = null
private var selectedByUi = false
init {
refresh()
}
override fun selectTab(tab: ShellTab) {
selectedByUi = true
mutableSnapshot.update { it.copy(selectedTab = tab) }
}
@@ -63,7 +65,13 @@ class NativeFixtureShellRepository internal constructor(
mutableSnapshot.update {
result.copy(
fixture = fixture,
selectedTab = if (it.pages.isEmpty()) result.selectedTab else it.selectedTab
selectedTab = if (!selectedByUi &&
it.pages.isEmpty()
) {
result.selectedTab
} else {
it.selectedTab
}
)
}
}
@@ -145,6 +153,7 @@ private fun MobileShellState.toFacade(): ShellState = when (this) {
MobileShellState.READY -> ShellState.Ready
MobileShellState.LOCKED -> ShellState.Locked
MobileShellState.ERROR -> ShellState.Error
MobileShellState.STALE -> ShellState.Stale
}
private fun ShellState.toNative(): MobileShellState = when (this) {
@@ -153,6 +162,7 @@ private fun ShellState.toNative(): MobileShellState = when (this) {
ShellState.Ready -> MobileShellState.READY
ShellState.Locked -> MobileShellState.LOCKED
ShellState.Error -> MobileShellState.ERROR
ShellState.Stale -> MobileShellState.STALE
}
private fun MobileTab.toFacade(): ShellTab = when (this) {
@@ -80,6 +80,7 @@ class NativeOnboardingRepository(
}
}
val discovery = request.discover()
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -116,6 +117,7 @@ class NativeOnboardingRepository(
}
override fun replaceToken(account: String, token: String) = runSetup {
newOperation().replaceToken(account, token)
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -143,6 +145,7 @@ class NativeOnboardingRepository(
}
}
private fun completed(outcome: MobileOnboardingOutcome) {
if (revoked.get()) throw interrupted()
updateStatus(requireNotNull(operation).status())
mutableSetup.update {
it.copy(title = outcome.title, detail = outcome.detail, branches = emptyList())
@@ -152,6 +155,7 @@ class NativeOnboardingRepository(
shell.refresh()
}
private fun updateStatus(status: MobileAndroidSetupStatus) {
if (revoked.get()) throw interrupted()
pollProgress = false
mutableSetup.update {
it.copy(
@@ -134,6 +134,11 @@ class NativePasswordRepository(
verified.present(page)
}
}
override fun leave() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
authentication.lifecycle(AuthenticationLifecycle.Background)
}
override fun lock() {
sequence.incrementAndGet()
mutable.value = mutable.value.cleared().copy(loading = false)
@@ -80,6 +80,7 @@ interface PasswordRepository {
fun inspect(id: String, revision: String)
fun back()
fun unlock(passphrase: String)
fun leave() = lock()
fun lock()
fun reveal(id: ULong)
fun hide(id: ULong)
@@ -12,23 +12,25 @@ class ProtectedPresentation {
windows += 1
return AutoCloseable { windows -= 1 }
}
private val metadata = linkedSetOf<() -> Unit>()
private val metadata = linkedMapOf<Any, () -> Unit>()
fun registerMetadata(clear: () -> Unit): AutoCloseable {
synchronized(metadata) { metadata.add(clear) }
return AutoCloseable { synchronized(metadata) { metadata.remove(clear) } }
val registration = Any()
synchronized(metadata) { metadata[registration] = clear }
return AutoCloseable { synchronized(metadata) { metadata.remove(registration) } }
}
fun clearMetadata() {
synchronized(metadata) { metadata.toList() }.forEach { it() }
synchronized(metadata) { metadata.values.toList() }.forEach { it() }
}
private val listeners = linkedSetOf<() -> Unit>()
private val listeners = linkedMapOf<Any, () -> Unit>()
private val mutableGeneration = MutableStateFlow(0L)
val generation: StateFlow<Long> = mutableGeneration
fun register(clear: () -> Unit): AutoCloseable {
synchronized(listeners) { listeners.add(clear) }
return AutoCloseable { synchronized(listeners) { listeners.remove(clear) } }
val registration = Any()
synchronized(listeners) { listeners[registration] = clear }
return AutoCloseable { synchronized(listeners) { listeners.remove(registration) } }
}
fun clear() {
synchronized(listeners) { listeners.toList() }.forEach { it() }
synchronized(listeners) { listeners.values.toList() }.forEach { it() }
mutableGeneration.value += 1
}
}
@@ -2,7 +2,7 @@ package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
enum class ShellState { Loading, Empty, Ready, Locked, Error }
enum class ShellState { Loading, Empty, Ready, Locked, Error, Stale }
enum class ShellTab { Home, Passwords, Search, Totp, Preferences }
enum class ShellIcon {
@@ -10,6 +10,19 @@ import org.junit.Test
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class NativeFixtureShellRepositoryTest {
@Test
fun restoredTabWinsOverInitialNativeCompletion() = runTest {
val pending = CompletableDeferred<Unit>()
val repository = NativeFixtureShellRepository(backgroundScope) { state ->
pending.await()
ShellSnapshot(state, emptyList(), selectedTab = ShellTab.Home)
}
repository.selectTab(ShellTab.Preferences)
pending.complete(Unit)
runCurrent()
assertEquals(ShellTab.Preferences, repository.snapshot.value.selectedTab)
}
@Test
fun cancelledNativeCompletionCannotReplaceNewerStateOrTabSelection() = runTest {
val pending = CompletableDeferred<Unit>()
@@ -29,4 +29,31 @@ class ProtectedPresentationTest {
assertEquals("disposed", secret)
assertEquals(2L, presentation.generation.value)
}
@Test fun overlappingScreensCannotUnregisterAnotherOwnersIdenticalCallback() {
val presentation = ProtectedPresentation()
for (metadata in listOf(false, true)) {
var calls = 0
val callback = { calls += 1 }
fun register() = if (metadata) {
presentation.registerMetadata(
callback
)
} else {
presentation.register(callback)
}
fun clear() = if (metadata) presentation.clearMetadata() else presentation.clear()
val first = register()
val second = register()
clear()
assertEquals(2, calls)
first.close()
first.close()
clear()
assertEquals(3, calls)
second.close()
clear()
assertEquals(3, calls)
}
}
}
@@ -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>