Complete Android adaptive and accessibility hardening (#105)
Android ARM verification / rust (push) Failing after 50m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-15 11:44:31 +02:00
1 parent f4a6edbc68
commit 45d78aa5b3
33 files changed
+4596 -1376

No files matched your search

@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -14,11 +15,16 @@ import de.rfc1437.ironstorage.core.CredentialCandidate
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CredentialSelectionScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private var selected by mutableStateOf<String?>(null)
private var destination by mutableStateOf("")
private var consent by mutableStateOf(false)
@@ -2,8 +2,12 @@ package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
@@ -20,10 +24,13 @@ 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.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
@@ -35,6 +42,7 @@ import de.rfc1437.ironstorage.core.EditorState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.EditorDiscardDialog
import de.rfc1437.ironstorage.features.EntryEditorScreen
import de.rfc1437.ironstorage.features.HingeSafeLayout
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -50,6 +58,55 @@ class EntryEditorScreenTest {
compose.enableAccessibilityChecks()
}
@Test fun tabletopGeneratorKeepsEveryControlReachableAtLargeFont() {
val repository = Fixture()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints {
val density = LocalDensity.current
val width = with(density) { maxWidth.toPx() }
val height = with(density) { maxHeight.toPx() }
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme {
HingeSafeLayout(
Posture(
true,
listOf(
HingeInfo(
Rect(
0f,
height * .5f,
width,
height * .5f + 12f
),
false,
false,
true,
true
)
)
)
) { EntryEditorScreen(repository, authentication) }
}
}
}
}
}
compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasText("Generate password"))
compose.onNodeWithText("Generate password").performClick()
compose.onNodeWithText("Password length").performScrollTo().performTextReplacement("24")
compose.onNodeWithTag("hinge-dialog-content").performTouchInput { swipeUp() }
compose.onNodeWithText("Use letters and numbers only").performScrollTo()
compose.onNodeWithText("Use letters and numbers only").assertIsDisplayed()
compose.onNodeWithText("Generate replacement").performScrollTo().performClick()
assertEquals(1, repository.generations)
assertEquals(24u, repository.length)
}
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
val repository = Fixture()
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
@@ -0,0 +1,98 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.features.HingeSafeLayout
import de.rfc1437.ironstorage.features.HomeScreen
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class HingeWindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun livePostureChangesKeepContentAndProtectedDialogOutsideTheHinge() {
compose.enableAccessibilityChecks()
val repository = HomeScreenTest.Fixture()
var mode by mutableStateOf(0)
var hinge = Rect.Zero
var density = 1f
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints(Modifier.fillMaxSize()) {
density = LocalDensity.current.density
val width = maxWidth.value * density
val height = maxHeight.value * density
hinge = if (mode == 1) {
Rect(width * .35f, 0f, width * .35f + 12f, height)
} else {
Rect(0f, height * .5f, width, height * .5f + 12f)
}
val posture = Posture(
isTabletop = mode == 2,
hingeList = if (mode == 0) {
emptyList()
} else {
listOf(
HingeInfo(hinge, false, mode == 1, true, true)
)
}
)
MaterialTheme {
HingeSafeLayout(posture) { HomeScreen(repository) }
}
}
}
}
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
compose.onNodeWithText("Commit message").performTextInput("Public posture fixture")
for (posture in listOf(1, 2, 0)) {
compose.runOnIdle { mode = posture }
if (posture != 0) {
compose.onNodeWithText("Create commit").performScrollTo()
}
val action = compose.onNodeWithText("Create commit")
action.assertIsDisplayed()
if (posture != 0) {
val bounds = action.getUnclippedBoundsInRoot()
val pixels = Rect(
bounds.left.value * density,
bounds.top.value * density,
bounds.right.value * density,
bounds.bottom.value * density
)
assertTrue("Dialog action must avoid the hinge", !pixels.overlaps(hinge))
if (posture == 2) assertTrue(pixels.bottom <= hinge.top)
compose.onNodeWithText("Public posture fixture").performScrollTo()
}
compose.onNodeWithText("Public posture fixture")
.assertTextContains("Public posture fixture")
}
compose.onNodeWithText("Create commit").performClick()
assertEquals(
HomeActionKind.Commit to "Public posture fixture",
repository.requests.single()
)
}
}
@@ -9,7 +9,6 @@ import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
@@ -93,12 +92,12 @@ class HomeScreenTest {
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
val repository = Fixture()
show(repository)
compose.onRoot().performTouchInput { swipeDown() }
compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
compose.waitUntil(5000) { repository.refreshes == 1 }
assertTrue(repository.requests.isEmpty())
compose.onNodeWithText("Cancel").performClick()
}
private class Fixture(changes: Int = 1) : HomeRepository {
internal class Fixture(changes: Int = 1) : HomeRepository {
private val actions = HomeActionKind.entries.map {
HomeAction(
it,
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
@@ -23,6 +24,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination
@@ -38,12 +40,17 @@ import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class MutationWorkflowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
@@ -55,7 +62,10 @@ class MutationWorkflowTest {
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
compose.onNodeWithText("Source").performTouchInput {
if (rtl) swipeRight() else swipeLeft()
}
compose.waitUntil(5000) { fixture.state.value.mutation != null }
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
assertEquals(0, fixture.committed)
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
@@ -49,6 +50,43 @@ class PresentationSecurityTest {
compose.enableAccessibilityChecks()
}
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
val dirty = mutableStateOf(true)
val registration = presentation.register { dirty.value = false }
try {
compose.runOnIdle {
compose.activity.setProtectedTestContent(presentation) {
MaterialTheme {
Text(
if (dirty.value) "Disposable protected fixture" else "Clean foreground"
)
}
}
}
compose.onNodeWithText("Disposable protected fixture").assertIsDisplayed()
compose.runOnIdle {
presentation.clear()
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
compose.activity.window.decorView.importantForAccessibility
)
assertFalse(dirty.value)
}
compose.onNodeWithText("Clean foreground").assertIsDisplayed()
compose.onNodeWithText("Disposable protected fixture").assertDoesNotExist()
compose.runOnIdle {
assertFalse(
"The rebuilt foreground surface must be visible to accessibility",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
} finally {
registration.close()
}
}
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
val done = CountDownLatch(1)
@@ -5,6 +5,7 @@ import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isHeading
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
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.performScrollTo
@@ -28,9 +29,7 @@ class ShellScreenTest {
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
ShellTab.entries.forEach { tab ->
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
compose.onNode(
hasText(page.title) and androidx.compose.ui.test.isSelectable()
).performClick()
compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
compose.waitUntil(timeoutMillis = 10_000) {
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
.fetchSemanticsNodes().size == 1
@@ -1,5 +1,9 @@
package de.rfc1437.ironstorage
import android.app.KeyguardManager
import android.os.Build
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
@@ -20,6 +24,7 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -30,10 +35,12 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SearchScreen
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.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -43,8 +50,32 @@ class Tier2WindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() {
compose.runOnIdle {
if (Build.VERSION.SDK_INT >= 27) compose.activity.setTurnScreenOn(true)
val keyguard = compose.activity.getSystemService(KeyguardManager::class.java)
if (!keyguard.isDeviceSecure) keyguard.requestDismissKeyguard(compose.activity, null)
}
compose.enableAccessibilityChecks()
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
if (InstrumentationRegistry.getArguments().getString("hardeningResize") == "true") {
val initialWidth = compose.activity.window.decorView.width
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(original.search, {}) }
}
}
compose.onNodeWithText("Entry name or path").performTextReplacement("appearance")
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putBoolean("ironstorage_resize_ready", true) }
)
compose.waitUntil(30000) { compose.activity.window.decorView.width != initialWidth }
compose.runOnIdle {
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
assertEquals("appearance", current.search.state.value.query)
current.search.clear()
}
}
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState())
@@ -72,7 +103,8 @@ class Tier2WindowTest {
val width =
compose.activity.window.decorView.width /
compose.activity.resources.displayMetrics.density
val navigation = if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
val navigation = InstrumentationRegistry.getArguments().getString("expectedNavigation")
?: if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
compose.onNodeWithTag(navigation).assertIsDisplayed()
compose.onNodeWithTag("settings-search").assertIsDisplayed()
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") {
@@ -12,6 +12,7 @@ import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
@@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -43,6 +45,10 @@ import org.junit.Test
class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,7 @@
package de.rfc1437.ironstorage.core
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
@@ -109,6 +111,7 @@ class NativeCredentialProviderTest {
emptyList()
)
}
val fillStart = SystemClock.elapsedRealtime()
val value = credentials.fill(
request.token,
caller,
@@ -117,6 +120,12 @@ class NativeCredentialProviderTest {
passphrase,
emptyList()
)
val fillMs = SystemClock.elapsedRealtime() - fillStart
assertTrue("Authenticated provider fill must finish within 5 s", fillMs < 5000)
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putLong("ironstorage_provider_authenticated_fill_ms", fillMs) }
)
assertEquals("alice", value.username)
assertEquals("fixture-password", value.password)
assertEquals("CredentialValue([REDACTED])", value.toString())
@@ -270,24 +270,24 @@ class NativeEntryViewerTest {
repeat(50) { directory ->
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
assertTrue(folder.mkdirs())
repeat(20) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
repeat(200) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
}
val storage = session.storage()
val navigateStart = android.os.SystemClock.elapsedRealtime()
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
assertEquals(
20,
200,
storage.passwordNavigation("load/slot-000", null, null, false).rows.size
)
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart
assertTrue("1000-entry navigation must finish in 3 s", navigateMs < 3000)
assertTrue("10000-entry navigation must finish in 3 s", navigateMs < 3000)
val searchStart = android.os.SystemClock.elapsedRealtime()
assertEquals(1000, storage.passwordNameSearch("load").size)
assertEquals(20, storage.passwordNameSearch("load/slot-019").size)
assertEquals(10000, storage.passwordNameSearch("load").size)
assertEquals(200, storage.passwordNameSearch("load/slot-019").size)
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
assertTrue("1000-entry name search must finish in 5 s", searchMs < 5000)
performance("tree_1000_navigation_ms", navigateMs)
performance("tree_1000_search_ms", searchMs)
assertTrue("10000-entry name search must finish in 5 s", searchMs < 5000)
performance("tree_10000_navigation_ms", navigateMs)
performance("tree_10000_search_ms", searchMs)
}
} finally {
withContext(Dispatchers.Main.immediate) { repository.lock() }
@@ -0,0 +1,126 @@
package de.rfc1437.ironstorage.core
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeTotpPerformanceTest {
@Test fun cachedCatalogAndProviderStayResponsiveWhileLocked(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, NativeEntryViewerTest.rootName).apply { mkdirs() }
}
val session = NativePrivateSession(context, {
PrivateDirectories(
roots[0].canonicalPath,
roots[1].canonicalPath,
roots[2].canonicalPath
)
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
val passphrase = "public-totp-performance-fixture"
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key =
requireNotNull(setup.generate("Performance fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
auth.authenticated { native ->
val editor = native.beginCreateEntry("", "seed", passphrase)
val page = native.addEntryEditorField(
editor.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
"otpauth://totp/Public:seed?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
)
native.saveEntryEditor(
editor.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
withContext(Dispatchers.IO) {
// Replicate only Rust-encrypted public fixture bytes in this isolated test root.
val vault = File(roots[1], "ironstorage/vault")
val ciphertext = File(vault, "seed.gpg").readBytes()
repeat(99) { File(vault, "seed-$it.gpg").writeBytes(ciphertext) }
}
auth.authenticated { native ->
mobileTotpOperation().use { operation ->
assertEquals(100u, native.refreshTotpCatalog(operation, null).progress.matches)
}
}
withContext(Dispatchers.Main.immediate) { auth.lock() }
measure("totp_cached_open_p95_us", 1_000_000) {
auth.metadata { native ->
mobileTotpOperation().use { operation ->
assertEquals(100, native.searchCachedTotpNavigation("", operation)!!.size)
}
}
assertFalse(auth.state.value.unlocked)
}
measure("totp_warm_refresh_p95_us", 3_000_000) {
auth.metadata { native ->
mobileTotpOperation().use { operation ->
val progress = native.refreshTotpCatalog(operation, null).progress
assertEquals(MobileTotpDiscoveryPhase.COMPLETE, progress.phase)
assertEquals(100u, progress.cacheHits)
assertEquals(100u, progress.matches)
}
}
assertFalse(auth.state.value.unlocked)
}
val credentials = NativeCredentialRepository(auth)
val caller =
CredentialCaller("public.performance.client", listOf("AA".repeat(32)), null)
measure("provider_prepare_p95_us", 1_000_000) {
val request = credentials.begin(caller)
try {
assertEquals(100, request.candidates.size)
assertFalse(auth.state.value.unlocked)
} finally {
credentials.cancel(request.token)
}
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
withContext(Dispatchers.IO) { roots.forEach { it.deleteRecursively() } }
}
}
private suspend fun measure(name: String, budgetUs: Long, action: suspend () -> Unit) {
repeat(10) { action() }
val samples = LongArray(30)
repeat(samples.size) { index ->
val start = SystemClock.elapsedRealtimeNanos()
action()
samples[index] = (SystemClock.elapsedRealtimeNanos() - start) / 1000
}
samples.sort()
val p95 = samples[28]
assertTrue("$name must finish within $budgetUs microseconds", p95 < budgetUs)
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putLong("ironstorage_$name", p95) }
)
}
}
@@ -2,8 +2,9 @@ package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -15,6 +16,7 @@ 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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
@@ -108,7 +110,7 @@ internal fun PassphraseDialog(
}
DisposableEffect(Unit) { onDispose { secret = "" } }
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = {
secret = ""
@@ -116,7 +118,10 @@ internal fun PassphraseDialog(
},
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ProtectedInputSurface(presentation)
Text(detail)
OutlinedTextField(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -45,108 +46,111 @@ fun CredentialSelectionScreen(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
LazyColumn(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
stringResource(
if (saving) R.string.provider_save_title else R.string.provider_fill_title
),
style = MaterialTheme.typography.headlineSmall
)
Text(request?.title ?: stringResource(R.string.provider_checking))
}
if (saving) {
HingeSafeLayout {
LazyColumn(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
OutlinedTextField(
destination,
onDestination,
label = { Text(stringResource(R.string.provider_destination)) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
ProviderConsent(
overwrite,
!busy,
stringResource(R.string.provider_overwrite),
onOverwrite
)
}
} else if (selected == null) {
item {
OutlinedTextField(
query,
onQuery,
label = { Text(stringResource(R.string.provider_search)) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
items(request?.candidates ?: emptyList(), key = { it.path }) { candidate ->
TextButton(onClick = {
onSelect(candidate.path)
}, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
Text(candidate.path)
}
}
} else {
item {
Text(stringResource(R.string.provider_selected, selected))
TextButton(onClick = { onSelect(null) }, enabled = !busy) {
Text(stringResource(R.string.provider_choose_another))
}
}
}
if (saving || selected != null) {
item {
OutlinedTextField(
passphrase,
onPassphrase,
label = { Text(stringResource(R.string.provider_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
),
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
val recipient = request?.title ?: stringResource(R.string.provider_this_application)
val confirmation = if (saving) {
stringResource(
R.string.provider_confirm_save
)
} else {
stringResource(R.string.provider_confirm_fill, recipient)
}
ProviderConsent(consent, !busy, confirmation, onConsent)
}
}
item {
failure?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = onConfirm,
enabled = !busy && consent && request != null &&
(if (saving) destination.isNotBlank() else selected != null)
) {
Text(
stringResource(
if (busy) {
R.string.provider_working
} else if (saving) {
R.string.provider_save
} else {
R.string.provider_fill
}
)
if (saving) R.string.provider_save_title else R.string.provider_fill_title
),
style = MaterialTheme.typography.headlineSmall
)
Text(request?.title ?: stringResource(R.string.provider_checking))
}
if (saving) {
item {
OutlinedTextField(
destination,
onDestination,
label = { Text(stringResource(R.string.provider_destination)) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
ProviderConsent(
overwrite,
!busy,
stringResource(R.string.provider_overwrite),
onOverwrite
)
}
} else if (selected == null) {
item {
OutlinedTextField(
query,
onQuery,
label = { Text(stringResource(R.string.provider_search)) },
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
items(request?.candidates ?: emptyList(), key = { it.path }) { candidate ->
TextButton(onClick = {
onSelect(candidate.path)
}, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
Text(candidate.path)
}
}
} else {
item {
Text(stringResource(R.string.provider_selected, selected))
TextButton(onClick = { onSelect(null) }, enabled = !busy) {
Text(stringResource(R.string.provider_choose_another))
}
}
}
if (saving || selected != null) {
item {
OutlinedTextField(
passphrase,
onPassphrase,
label = { Text(stringResource(R.string.provider_passphrase)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
autoCorrectEnabled = false
),
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
val recipient =
request?.title ?: stringResource(R.string.provider_this_application)
val confirmation = if (saving) {
stringResource(
R.string.provider_confirm_save
)
} else {
stringResource(R.string.provider_confirm_fill, recipient)
}
ProviderConsent(consent, !busy, confirmation, onConsent)
}
}
item {
failure?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = onConfirm,
enabled = !busy && consent && request != null &&
(if (saving) destination.isNotBlank() else selected != null)
) {
Text(
stringResource(
if (busy) {
R.string.provider_working
} else if (saving) {
R.string.provider_save
} else {
R.string.provider_fill
}
)
)
}
TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) }
}
TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) }
}
}
}
@@ -159,7 +163,7 @@ private fun ProviderConsent(
onChange: (Boolean) -> Unit
) {
Row(
Modifier.fillMaxWidth().toggleable(
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
value = checked,
enabled = enabled,
role = Role.Checkbox,
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -287,11 +288,16 @@ fun EditorDiscardDialog(repository: EditorRepository) {
val state by repository.state.collectAsStateWithLifecycle()
if (!state.discardRequested) return
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = repository::keepEditing,
title = { Text(stringResource(R.string.editor_discard_title)) },
text = { Text(stringResource(R.string.editor_discard_detail)) },
text = {
Text(
stringResource(R.string.editor_discard_detail),
Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(onClick = repository::confirmDiscard) {
Text(stringResource(R.string.editor_discard))
@@ -320,7 +326,7 @@ private fun EditorAddDialog(
var name by rememberProtectedInput(authentication.presentation)
var value by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
@@ -338,7 +344,7 @@ private fun EditorAddDialog(
},
text = {
ProtectedInputSurface(authentication.presentation)
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (kind ==
EditorFieldKind.Field
@@ -399,12 +405,12 @@ private fun EditorGenerateDialog(
var noSymbols by remember { mutableStateOf(false) }
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = { Text(stringResource(R.string.editor_generate)) },
text = {
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(stringResource(R.string.editor_generate_detail))
OutlinedTextField(
length,
@@ -466,7 +472,7 @@ internal fun EditorCreationDialog(
) {
var name by rememberProtectedInput(authentication.presentation)
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
@@ -12,7 +12,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -73,7 +72,7 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
if (edit) {
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
onDismissRequest = { edit = false },
title = { Text(stringResource(R.string.entry_edit)) },
text = {
@@ -215,12 +214,12 @@ fun EntryDialogs(
}
}
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
onDismissRequest = repository::dismissEdit,
title = { Text(edit.label) },
text = {
CompositionLocalProvider(LocalClipboard provides clipboard) {
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
ProtectedInputSurface(authentication.presentation)
OutlinedTextField(
value,
@@ -0,0 +1,108 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
internal fun HingeSafeAlertDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
dismissButton: (@Composable () -> Unit)? = null,
title: (@Composable () -> Unit)? = null,
text: (@Composable () -> Unit)? = null,
properties: DialogProperties = DialogProperties()
) {
val posture = LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
if (posture.hingeList.none { it.isSeparating || it.isOccluding }) {
AlertDialog(
onDismissRequest,
confirmButton,
dismissButton = dismissButton,
title = title,
text = text,
properties = properties
)
return
}
Dialog(
onDismissRequest,
DialogProperties(
dismissOnBackPress = properties.dismissOnBackPress,
dismissOnClickOutside = properties.dismissOnClickOutside,
securePolicy = properties.securePolicy,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
ProtectedInputSurface()
HingeSafeLayout(posture) {
val paneHeight = maxHeight
Box(
Modifier.fillMaxSize().systemBarsPadding().imePadding(),
contentAlignment = Alignment.Center
) {
Surface(
Modifier.padding(16.dp).widthIn(max = 560.dp).fillMaxWidth()
.heightIn(max = (paneHeight - 32.dp).coerceAtLeast(0.dp)),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp
) {
Column(
Modifier.padding(24.dp).verticalScroll(rememberScrollState())
.testTag("hinge-dialog-content"),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
title?.let {
Box(Modifier.semantics { heading() }) {
ProvideTextStyle(MaterialTheme.typography.headlineSmall) {
it()
}
}
}
text?.let {
Box(
Modifier.heightIn(
max = (paneHeight - 80.dp).coerceAtLeast(0.dp)
)
) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { it() }
}
}
FlowRow(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
dismissButton?.invoke()
confirmButton()
}
}
}
}
}
}
}
@@ -0,0 +1,111 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.roundToInt
internal val LocalWindowPosture = staticCompositionLocalOf<Posture?> { null }
/** Keep tabletop content above the fold; otherwise use the largest unobscured pane. */
@Composable
fun HingeSafeLayout(
posture: Posture? = null,
content: @Composable BoxWithConstraintsScope.() -> Unit
) {
val current =
posture ?: LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
BoxWithConstraints(
Modifier.fillMaxSize().onGloballyPositioned { origin = it.positionInWindow() }
) {
val bounds = with(density) { Rect(origin, Size(maxWidth.toPx(), maxHeight.toPx())) }
val pane = hingeSafeBounds(bounds, current)
val modifier = with(density) {
Modifier.absoluteOffset(
(pane.left - bounds.left).toDp(),
(pane.top - bounds.top).toDp()
)
.size(pane.width.toDp(), pane.height.toDp())
.consumeWindowInsets(
WindowInsets(
left = (pane.left - bounds.left).roundToInt(),
top = (pane.top - bounds.top).roundToInt(),
right = (bounds.right - pane.right).roundToInt(),
bottom = (bounds.bottom - pane.bottom).roundToInt()
)
)
}
CompositionLocalProvider(LocalWindowPosture provides current) {
BoxWithConstraints(modifier) { content() }
}
}
}
internal fun hingeSafeBounds(bounds: Rect, posture: Posture): Rect {
var panes = listOf(bounds)
for (hinge in posture.hingeList.filter { it.isSeparating || it.isOccluding }) {
panes = panes.flatMap { pane ->
if (!pane.overlaps(hinge.bounds)) {
listOf(pane)
} else if (hinge.isVertical) {
listOf(
Rect(
pane.left,
pane.top,
hinge.bounds.left.coerceIn(pane.left, pane.right),
pane.bottom
),
Rect(
hinge.bounds.right.coerceIn(pane.left, pane.right),
pane.top,
pane.right,
pane.bottom
)
)
} else {
listOf(
Rect(
pane.left,
pane.top,
pane.right,
hinge.bounds.top.coerceIn(pane.top, pane.bottom)
),
Rect(
pane.left,
hinge.bounds.bottom.coerceIn(pane.top, pane.bottom),
pane.right,
pane.bottom
)
)
}
}.filter { it.width > 0 && it.height > 0 }
}
val pane = if (posture.isTabletop) {
panes.minWithOrNull(compareBy<Rect> { it.top }.thenByDescending { it.width * it.height })
} else {
panes.maxByOrNull { it.width * it.height }
}
return pane ?: Rect(bounds.topLeft, Size.Zero)
}
@@ -8,7 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -208,7 +209,7 @@ fun HomeScreen(
}
if (committing) {
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
onDismissRequest = {
committing = false
message = ""
@@ -216,7 +217,7 @@ fun HomeScreen(
title = { Text(stringResource(R.string.home_commit_title)) },
text = {
ProtectedInputSurface()
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(state.name)
Text(state.email)
OutlinedTextField(
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
@@ -67,7 +66,7 @@ internal fun MutationDialog(
if (lease.busy) authentication.cancel()
}
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = cancel,
title = { Text(stringResource(plan.action.label(plan.kind))) },
@@ -112,7 +111,9 @@ internal fun MutationDialog(
)
)
Row(
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
Modifier.fillMaxWidth().heightIn(
min = 48.dp
).toggleable(discard, role = Role.Checkbox) {
discard = it
}
) {
@@ -127,7 +128,7 @@ internal fun MutationDialog(
if (plan.action != PasswordMutation.Delete) {
items(plan.destinations, key = { it.path }) { candidate ->
Row(
Modifier.fillMaxWidth().padding(
Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(
start = (
candidate.depth.coerceAtMost(12u).toInt() *
12
@@ -162,7 +163,7 @@ internal fun MutationDialog(
Text(stringResource(R.string.mutation_collision))
target.collisionTargets.forEach { Text(it) }
Row(
Modifier.fillMaxWidth().toggleable(
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
overwrite,
role = Role.Checkbox
) {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
@@ -127,7 +128,7 @@ fun OnboardingScreen(
Text(stringResource(R.string.setup_choose_key))
state.keys.forEach { item ->
Row(
Modifier.selectable(
Modifier.heightIn(min = 48.dp).selectable(
selected = selectedKey == item.fingerprint,
role = Role.RadioButton,
onClick = { key = item.fingerprint }
@@ -253,7 +254,7 @@ fun OnboardingScreen(
"branch" -> {
state.branches.forEach { item ->
Row(
Modifier.selectable(
Modifier.heightIn(min = 48.dp).selectable(
selected = selectedBranch == item,
role = Role.RadioButton,
onClick = { branch = item }
@@ -268,7 +269,9 @@ fun OnboardingScreen(
it
}
Row(
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
Modifier.heightIn(
min = 48.dp
).toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
reuse =
it
})
@@ -4,8 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -138,11 +139,16 @@ internal fun OtpCodeView(
}
if (confirmation) {
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { confirmation = false },
title = { Text(stringResource(R.string.otp_generate_hotp)) },
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
text = {
Text(
stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString()),
Modifier.verticalScroll(rememberScrollState())
)
},
confirmButton = {
TextButton(enabled = !busy, onClick = {
confirmation = false
@@ -13,7 +13,8 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
@@ -432,14 +433,14 @@ fun PasswordScreen(
if (actions && state.selected != null && !state.loading) {
val selected = requireNotNull(state.selected)
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
onDismissRequest = { actions = false },
title = {
Text(selected.title)
},
text = {
ProtectedInputSurface()
Column {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(selected.detail + "\n" + selected.path)
PasswordMutation.entries.forEach { action ->
TextButton(onClick = {
@@ -44,7 +44,8 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
val view = LocalView.current.rootView
val surface = remember(view) { protectedSurface(view) }
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) }
val generation by presentation.generation.collectAsState()
// Read here so cleanup also reruns the parent surface's reveal effect.
val generation = presentation.generation.collectAsState().value
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
DisposableEffect(presentation, view) {
val registration = presentation.register(surface::cover)
@@ -77,7 +78,7 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
}
val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
SideEffect {
if (revealCleanContent) {
if (revealCleanContent && generation == presentation.generation.value) {
surface.uncover()
} else {
surface.cover()
@@ -8,13 +8,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -308,7 +308,7 @@ fun SettingsScreen(
resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() }
}
}
AlertDialog(
HingeSafeAlertDialog(
onDismissRequest = {
licenses = false
},
@@ -358,7 +358,7 @@ private fun SettingsEditDialog(
timeout = state.page?.timeout?.toString().orEmpty()
}
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close,
title = {
@@ -455,7 +455,7 @@ internal fun KeyTransferDialog(
}
}
ProtectedWindow()
AlertDialog(
HingeSafeAlertDialog(
properties = DialogProperties(
securePolicy = SecureFlagPolicy.SecureOn,
usePlatformDefaultWidth = false
@@ -475,7 +475,7 @@ internal fun KeyTransferDialog(
if (!state.transfer.setup) {
state.transfer.keys.forEach { key ->
Row(
Modifier.fillMaxWidth().selectable(
Modifier.fillMaxWidth().heightIn(min = 48.dp).selectable(
selected = fingerprint == key.fingerprint,
enabled = !state.busy,
role = Role.RadioButton,
@@ -675,7 +675,7 @@ internal fun KeyTransferDialog(
@Composable
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().toggleable(
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
value = checked,
role = Role.Checkbox,
onValueChange = change
@@ -1,7 +1,6 @@
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
@@ -78,7 +77,7 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
listOf(route)
}
// Actual window bounds, rather than device type, also handle desktop and split-screen resize.
BoxWithConstraints(Modifier.fillMaxSize()) {
HingeSafeLayout {
val rail = maxWidth >= 600.dp
val largeFont = LocalDensity.current.fontScale >= 1.6f
Scaffold(
@@ -0,0 +1,66 @@
package de.rfc1437.ironstorage.features
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.ui.geometry.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
class HingeSafeLayoutTest {
private val window = Rect(10f, 20f, 1010f, 820f)
@Test fun bookTabletopAndZeroWidthSeparatorsKeepTheLargestPane() {
assertEquals(
Rect(430f, 20f, 1010f, 820f),
boundsFor(listOf(hinge(Rect(410f, 20f, 430f, 820f), true)))
)
assertEquals(
Rect(10f, 340f, 1010f, 820f),
boundsFor(listOf(hinge(Rect(10f, 320f, 1010f, 340f), false)))
)
assertEquals(
Rect(410f, 20f, 1010f, 820f),
boundsFor(listOf(hinge(Rect(410f, 20f, 410f, 820f), true)))
)
}
@Test fun flatCreasesAndHingesOutsideTheWindowDoNotReduceUsableSpace() {
assertEquals(window, boundsFor(emptyList()))
assertEquals(
window,
boundsFor(listOf(HingeInfo(window, true, true, false, false)))
)
assertEquals(
window,
boundsFor(listOf(hinge(Rect(1500f, 0f, 1520f, 900f), true)))
)
}
@Test fun multipleOccludingHingesNeverReturnAnOccludedPane() {
assertEquals(
Rect(430f, 340f, 1010f, 820f),
boundsFor(
listOf(
hinge(Rect(410f, 20f, 430f, 820f), true),
hinge(Rect(10f, 320f, 1010f, 340f), false)
)
)
)
}
@Test fun tabletopKeepsContentAboveTheFoldEvenWhenTheLowerPaneIsLarger() {
assertEquals(
Rect(10f, 20f, 1010f, 320f),
hingeSafeBounds(
window,
Posture(true, listOf(hinge(Rect(10f, 320f, 1010f, 340f), false)))
)
)
}
private fun boundsFor(hinges: List<HingeInfo>) =
hingeSafeBounds(window, Posture(hingeList = hinges))
private fun hinge(bounds: Rect, vertical: Boolean) =
HingeInfo(bounds, false, vertical, true, true)
}
@@ -29,5 +29,18 @@ class BaselineProfile {
"Searchable settings must finish loading",
device.wait(Until.findObject(By.text("Search settings")), 10000)
)
for ((destination, ready) in listOf(
"Passwords" to "Password Store",
"Search" to "Entry name or path",
"TOTP" to "Search cached TOTP entries"
)) {
val navigation = device.wait(Until.findObject(By.text(destination)), 10000)
assertNotNull("Cached navigation must be available", navigation)
navigation.click()
assertNotNull(
"Cached destination must finish loading",
device.wait(Until.findObject(By.text(ready)), 10000)
)
}
}
}
@@ -1,5 +1,6 @@
package de.rfc1437.ironstorage.platform
import android.os.Build
import android.security.keystore.KeyInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.filters.SdkSuppress
@@ -35,7 +36,7 @@ class AndroidKeystoreTest {
) = KeystoreKeySpecification(
alias,
auth,
true,
Build.VERSION.SDK_INT >= 28,
auth == KeystoreAuthorization.StrongBiometric,
false
)
@@ -51,6 +52,23 @@ class AndroidKeystoreTest {
}
}
@Test
@SdkSuppress(maxSdkVersion = 27)
fun unsupportedUnlockedDeviceFlagDoesNotCreateAKey() {
val os = AndroidKeystoreAdapter(context)
val alias = alias()
try {
failure(KeystoreFailure.UnsupportedProtection) {
os.createKey(
KeystoreKeySpecification(alias, KeystoreAuthorization.None, true, false, false)
)
}
assertFalse(os.aliases().contains(alias))
} finally {
os.deleteKey(alias)
}
}
@Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() {
val os = AndroidKeystoreAdapter(context)
assertFalse(os.deviceState().locked)
@@ -2,16 +2,51 @@ package de.rfc1437.ironstorage.platform
import android.Manifest
import android.content.pm.PackageManager
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidQrCameraTest {
@Test fun backgroundStopsAnalysisAndResumeRestartsPreview() {
val frames = AtomicInteger()
val received = AtomicReference(CountDownLatch(1))
ActivityScenario.launch(KeystoreTestActivity::class.java).use { activity ->
var camera: AndroidQrCamera? = null
activity.onActivity { host ->
camera = AndroidQrCamera(host, { frame ->
try {
frames.incrementAndGet()
received.get().countDown()
} finally {
frame.pixels.fill(0)
}
}, { throw AssertionError("CameraX failed to bind") })
host.setContentView(requireNotNull(camera).view)
}
try {
assertTrue(received.get().await(15, TimeUnit.SECONDS))
activity.moveToState(Lifecycle.State.CREATED)
Thread.sleep(500)
val stopped = frames.get()
Thread.sleep(500)
assertEquals("Background camera analysis must stop", stopped, frames.get())
received.set(CountDownLatch(1))
activity.moveToState(Lifecycle.State.RESUMED)
assertTrue("Camera preview must resume", received.get().await(15, TimeUnit.SECONDS))
} finally {
activity.onActivity { camera?.close() }
}
}
}
@Test fun livePreviewDeliversBoundedPlanesAndCanBeClosedAndReopened() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals(