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)
}
}
}
}