Implement adaptive Android shell and safe navigation (#102)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user