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

+80 -14
View File
@@ -251,8 +251,12 @@ PERFORMANCE_BUDGETS = {
"editor_update_p95_us": 32000, "editor_update_p95_us": 32000,
"cache_rebuild_ms": 15000, "cache_rebuild_ms": 15000,
"qr_analysis_ms": 5000, "qr_analysis_ms": 5000,
"tree_1000_navigation_ms": 3000, "totp_cached_open_p95_us": 1000000,
"tree_1000_search_ms": 5000, "totp_warm_refresh_p95_us": 3000000,
"provider_prepare_p95_us": 1000000,
"provider_authenticated_fill_ms": 5000,
"tree_10000_navigation_ms": 3000,
"tree_10000_search_ms": 5000,
} }
@@ -270,15 +274,37 @@ def performance_metrics(result):
return values return values
def emulator_ready(user_state, power_state, trust_state):
return (user_state == "RUNNING_UNLOCKED" and
re.search(r"^\s*mWakefulness=Awake$", power_state, re.MULTILINE) is not None and
re.search(r'^\s*User .*\(id=0,.*deviceLocked=(?:0|false)\b', trust_state, re.MULTILINE) is not None)
def instrumentation(command, package, arguments=(), target=None):
try:
return command("shell", "am", "instrument", "-w", "-r", *arguments,
package + "/androidx.test.runner.AndroidJUnitRunner")
finally:
try:
command("shell", "am", "force-stop", package)
finally:
if target is not None and target != package:
command("shell", "am", "force-stop", target)
def emulator(serial): def emulator(serial):
adb = SDK / "platform-tools/adb" adb = SDK / "platform-tools/adb"
def command(*args): def command(*args):
return run(adb, "-s", serial, *args, capture=True).stdout.strip() return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
abi = command("shell", "getprop", "ro.product.cpu.abi") abi = command("shell", "getprop", "ro.product.cpu.abi")
if abi not in ABIS: if abi not in ABIS:
raise RuntimeError("Only ARM emulators are in scope") raise RuntimeError("Only ARM emulators are in scope")
if command("shell", "getprop", "ro.kernel.qemu") != "1": if command("shell", "getprop", "ro.kernel.qemu") != "1":
raise RuntimeError("Physical devices are outside validation scope") raise RuntimeError("Physical devices are outside validation scope")
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
command("shell", "dumpsys", "power"),
command("shell", "dumpsys", "trust")):
raise RuntimeError("Wake and unlock the Android emulator before starting instrumentation")
api = int(command("shell", "getprop", "ro.build.version.sdk")) api = int(command("shell", "getprop", "ro.build.version.sdk"))
try: try:
pages = int(command("shell", "getconf", "PAGE_SIZE")) pages = int(command("shell", "getconf", "PAGE_SIZE"))
@@ -295,21 +321,37 @@ def emulator(serial):
# Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation. # Android's test runner and Rust fixtures cover typed errors, callbacks and cancellation.
suites = [("app", "de.rfc1437.ironstorage.test", None), suites = [("app", "de.rfc1437.ironstorage.test", None),
("core", "de.rfc1437.ironstorage.core.test", None), ("core", "de.rfc1437.ironstorage.core.test", None),
("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None)] ("rust-bridge", "de.rfc1437.ironstorage.rustbridge.test", None),
("platform", "de.rfc1437.ironstorage.platform.test",
"de.rfc1437.ironstorage.platform.AndroidQrCameraTest,"
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#deviceUnlockedRoundtripReplaceDeleteAndTamper,"
"de.rfc1437.ironstorage.platform.AndroidKeystoreTest#unsupportedUnlockedDeviceFlagDoesNotCreateAKey")]
native_timings = [] native_timings = []
journey_timings = {} journey_timings = {}
for module, package, _ in suites: for module, package, classes in suites:
test_apk = next((ROOT / "android" / module / "build/outputs/apk/androidTest/debug").glob("*.apk")) test_apk = next((ROOT / "android" / module / "build/outputs/apk/androidTest/debug").glob("*.apk"))
command("install", "-r", test_apk) command("install", "-r", test_apk)
if module == "rust-bridge": if module == "rust-bridge":
for _ in range(5): for _ in range(5):
command("shell", "am", "force-stop", package) command("shell", "am", "force-stop", package)
measured = command("shell", "am", "instrument", "-w", "-r", "-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest", package + "/androidx.test.runner.AndroidJUnitRunner") measured = instrumentation(command, package, ("-e", "class", "de.rfc1437.ironstorage.bridge.NativeLoadPerformanceTest"))
samples = [int(line.split("=", 1)[1]) for line in measured.splitlines() if line.startswith("INSTRUMENTATION_STATUS: ironstorage_native_load_ns=")] samples = [int(line.split("=", 1)[1]) for line in measured.splitlines() if line.startswith("INSTRUMENTATION_STATUS: ironstorage_native_load_ns=")]
if len(samples) != 1 or "OK (1 test)" not in measured: if len(samples) != 1 or "OK (1 test)" not in measured:
raise RuntimeError("Native cold-load measurement failed") raise RuntimeError("Native cold-load measurement failed")
native_timings.extend(samples) native_timings.extend(samples)
result = command("shell", "am", "instrument", "-w", "-r", package + "/androidx.test.runner.AndroidJUnitRunner") camera_granted = None
if module == "platform":
camera_granted = "android.permission.CAMERA: granted=true" in command("shell", "dumpsys", "package", package)
if not camera_granted:
command("shell", "pm", "grant", package, "android.permission.CAMERA")
arguments = ["-e", "class", classes] if classes else []
print(f"ARM API {api}, {pages}-byte pages: invoking {module} instrumentation", flush=True)
try:
result = instrumentation(command, package, arguments,
"de.rfc1437.ironstorage" if module == "app" else package)
finally:
if camera_granted is False:
command("shell", "pm", "revoke", package, "android.permission.CAMERA")
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result: if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
local = ROOT / "android/build/local-validation" local = ROOT / "android/build/local-validation"
local.mkdir(parents=True, exist_ok=True) local.mkdir(parents=True, exist_ok=True)
@@ -320,8 +362,7 @@ def emulator(serial):
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)") raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
if module == "core": if module == "core":
journey_timings = performance_metrics(result) journey_timings = performance_metrics(result)
# Release instrumentation-owned activities and native owners before real app starts. print(f"ARM API {api}, {pages}-byte pages: {module} instrumentation passed", flush=True)
command("shell", "am", "force-stop", package)
# No activity state or application data is deleted between process recreation runs. # No activity state or application data is deleted between process recreation runs.
timings = [] timings = []
for _ in range(5): for _ in range(5):
@@ -364,11 +405,15 @@ def startup_metrics(data):
def performance(serial, profiles=False): def performance(serial, profiles=False):
adb = SDK / "platform-tools/adb" adb = SDK / "platform-tools/adb"
def command(*args): def command(*args):
return run(adb, "-s", serial, *args, capture=True).stdout.strip() return run(adb, "-s", serial, *args, capture=True, timeout=900).stdout.strip()
abi = command("shell", "getprop", "ro.product.cpu.abi") abi = command("shell", "getprop", "ro.product.cpu.abi")
api = int(command("shell", "getprop", "ro.build.version.sdk")) api = int(command("shell", "getprop", "ro.build.version.sdk"))
if abi not in ABIS or command("shell", "getprop", "ro.kernel.qemu") != "1": if abi not in ABIS or command("shell", "getprop", "ro.kernel.qemu") != "1":
raise RuntimeError("Performance validation requires an ARM emulator") raise RuntimeError("Performance validation requires an ARM emulator")
if not emulator_ready(command("shell", "am", "get-started-user-state", "0"),
command("shell", "dumpsys", "power"),
command("shell", "dumpsys", "trust")):
raise RuntimeError("Wake and unlock the Android emulator before performance validation")
if profiles and api < 34: if profiles and api < 34:
raise RuntimeError("Data-preserving baseline generation requires API 34 or newer") raise RuntimeError("Data-preserving baseline generation requires API 34 or newer")
variant = "profile" if profiles else "benchmark" variant = "profile" if profiles else "benchmark"
@@ -379,15 +424,16 @@ def performance(serial, profiles=False):
directory = "/sdcard/Android/media/" + harness + "/additional_test_output" directory = "/sdcard/Android/media/" + harness + "/additional_test_output"
command("shell", "mkdir", "-p", directory) command("shell", "mkdir", "-p", directory)
test = "BaselineProfile" if profiles else "StartupBenchmark" test = "BaselineProfile" if profiles else "StartupBenchmark"
result = command("shell", "am", "instrument", "-w", "-r", "-e", "class", harness + "." + test, print(f"ARM API {api}: invoking {test} instrumentation", flush=True)
"-e", "additionalTestOutputDir", directory, "-e", "androidx.benchmark.suppressErrors", "EMULATOR", result = instrumentation(command, harness, ("-e", "class", harness + "." + test,
harness + "/androidx.test.runner.AndroidJUnitRunner") "-e", "additionalTestOutputDir", directory,
"-e", "androidx.benchmark.suppressErrors", "EMULATOR"),
"de.rfc1437.ironstorage")
local = ROOT / "android/build/local-validation" / ("performance-" + serial) local = ROOT / "android/build/local-validation" / ("performance-" + serial)
local.mkdir(parents=True, exist_ok=True) local.mkdir(parents=True, exist_ok=True)
local.chmod(0o700) local.chmod(0o700)
(local / (test + ".txt")).write_text(result) (local / (test + ".txt")).write_text(result)
(local / (test + ".txt")).chmod(0o600) (local / (test + ".txt")).chmod(0o600)
command("shell", "am", "force-stop", harness)
if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result: if "FAILURES!!!" in result or "INSTRUMENTATION_FAILED" in result or "OK (" not in result:
raise RuntimeError("Performance instrumentation failed; raw output stays local") raise RuntimeError("Performance instrumentation failed; raw output stays local")
command("pull", directory, local) command("pull", directory, local)
@@ -402,6 +448,26 @@ def performance(serial, profiles=False):
class SelfTest(unittest.TestCase): class SelfTest(unittest.TestCase):
def test_instrumentation_stops_harness_and_target_on_failure(self):
calls = []
def command(*args):
calls.append(args)
if args[2] == "instrument":
raise RuntimeError("disconnected emulator")
with self.assertRaisesRegex(RuntimeError, "disconnected emulator"):
instrumentation(command, "fixture.test", target="fixture")
self.assertEqual(calls[-2:], [("shell", "am", "force-stop", "fixture.test"),
("shell", "am", "force-stop", "fixture")])
def test_emulator_preflight_rejects_post_reboot_lock_and_sleep(self):
unlocked = ' User "Owner" (id=0, flags=0x13) (current): deviceLocked=0, strongAuthRequired=0x0'
self.assertTrue(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_LOCKED", " mWakefulness=Awake\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Asleep\n", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", "", unlocked))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", unlocked.replace("deviceLocked=0", "deviceLocked=1")))
self.assertFalse(emulator_ready("RUNNING_UNLOCKED", " mWakefulness=Awake\n", ""))
def test_startup_requires_complete_bounded_numeric_samples(self): def test_startup_requires_complete_bounded_numeric_samples(self):
def data(): def data():
return {"benchmarks": [{"name": name, "repeatIterations": 5, "metrics": { return {"benchmarks": [{"name": name, "repeatIterations": 5, "metrics": {
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled 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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick 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.core.ProviderRequest
import de.rfc1437.ironstorage.features.CredentialSelectionScreen import de.rfc1437.ironstorage.features.CredentialSelectionScreen
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
class CredentialSelectionScreenTest { class CredentialSelectionScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>() @get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private var selected by mutableStateOf<String?>(null) private var selected by mutableStateOf<String?>(null)
private var destination by mutableStateOf("") private var destination by mutableStateOf("")
private var consent by mutableStateOf(false) private var consent by mutableStateOf(false)
@@ -2,8 +2,12 @@ package de.rfc1437.ironstorage
import android.os.Bundle import android.os.Bundle
import android.os.Parcel import android.os.Parcel
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme 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.runtime.CompositionLocalProvider
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key 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.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository 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.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.EditorDiscardDialog import de.rfc1437.ironstorage.features.EditorDiscardDialog
import de.rfc1437.ironstorage.features.EntryEditorScreen import de.rfc1437.ironstorage.features.EntryEditorScreen
import de.rfc1437.ironstorage.features.HingeSafeLayout
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -50,6 +58,55 @@ class EntryEditorScreenTest {
compose.enableAccessibilityChecks() 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() { @Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
val repository = Fixture() val repository = Fixture()
val lease = MutableStateFlow(AuthenticationState(unlocked = true)) 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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
@@ -93,12 +92,12 @@ class HomeScreenTest {
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() { @Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
val repository = Fixture() val repository = Fixture()
show(repository) show(repository)
compose.onRoot().performTouchInput { swipeDown() } compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
compose.waitUntil(5000) { repository.refreshes == 1 } compose.waitUntil(5000) { repository.refreshes == 1 }
assertTrue(repository.requests.isEmpty()) assertTrue(repository.requests.isEmpty())
compose.onNodeWithText("Cancel").performClick() compose.onNodeWithText("Cancel").performClick()
} }
private class Fixture(changes: Int = 1) : HomeRepository { internal class Fixture(changes: Int = 1) : HomeRepository {
private val actions = HomeActionKind.entries.map { private val actions = HomeActionKind.entries.map {
HomeAction( HomeAction(
it, it,
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText 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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag 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.performScrollToNode
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationState import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination import de.rfc1437.ironstorage.core.PasswordDestination
@@ -38,12 +40,17 @@ import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
class MutationWorkflowTest { class MutationWorkflowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>() @get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture) { private fun display(fixture: Fixture) {
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent { compose.activity.setProtectedTestContent {
@@ -55,7 +62,10 @@ class MutationWorkflowTest {
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() { @Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
val fixture = Fixture() val fixture = Fixture()
display(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 } compose.waitUntil(5000) { fixture.state.value.mutation != null }
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action) assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
assertEquals(0, fixture.committed) assertEquals(0, fixture.committed)
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.InterceptPlatformTextInput import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
@@ -49,6 +50,43 @@ class PresentationSecurityTest {
compose.enableAccessibilityChecks() 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() { @Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true") assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
val done = CountDownLatch(1) 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.isHeading
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule 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.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
@@ -28,9 +29,7 @@ class ShellScreenTest {
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick() compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
ShellTab.entries.forEach { tab -> ShellTab.entries.forEach { tab ->
val page = ShellFixtures.all.getValue(state).first { it.tab == tab } val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
compose.onNode( compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
hasText(page.title) and androidx.compose.ui.test.isSelectable()
).performClick()
compose.waitUntil(timeoutMillis = 10_000) { compose.waitUntil(timeoutMillis = 10_000) {
compose.onAllNodes(hasText(page.stateTitle) and isHeading()) compose.onAllNodes(hasText(page.stateTitle) and isHeading())
.fetchSemanticsNodes().size == 1 .fetchSemanticsNodes().size == 1
@@ -1,5 +1,9 @@
package de.rfc1437.ironstorage 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.InputMode
import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key 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.performScrollTo
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat 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.AuthenticationState
import de.rfc1437.ironstorage.core.ProtectedPresentation import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ApplicationTheme import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SearchScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -43,8 +50,32 @@ class Tier2WindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>() @get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() { @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() compose.enableAccessibilityChecks()
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java] 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 setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val authentication = object : AuthenticationRepository by original.authentication { val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState()) override val state = MutableStateFlow(AuthenticationState())
@@ -72,7 +103,8 @@ class Tier2WindowTest {
val width = val width =
compose.activity.window.decorView.width / compose.activity.window.decorView.width /
compose.activity.resources.displayMetrics.density 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(navigation).assertIsDisplayed()
compose.onNodeWithTag("settings-search").assertIsDisplayed() compose.onNodeWithTag("settings-search").assertIsDisplayed()
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") { 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.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals 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.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onLast
@@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -43,6 +45,10 @@ import org.junit.Test
class TotpScreenTest { class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>() @get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture, fontScale: Float = 1f) { private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent { compose.activity.setProtectedTestContent {
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,7 @@
package de.rfc1437.ironstorage.core package de.rfc1437.ironstorage.core
import android.os.Bundle
import android.os.SystemClock
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
@@ -109,6 +111,7 @@ class NativeCredentialProviderTest {
emptyList() emptyList()
) )
} }
val fillStart = SystemClock.elapsedRealtime()
val value = credentials.fill( val value = credentials.fill(
request.token, request.token,
caller, caller,
@@ -117,6 +120,12 @@ class NativeCredentialProviderTest {
passphrase, passphrase,
emptyList() 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("alice", value.username)
assertEquals("fixture-password", value.password) assertEquals("fixture-password", value.password)
assertEquals("CredentialValue([REDACTED])", value.toString()) assertEquals("CredentialValue([REDACTED])", value.toString())
@@ -270,24 +270,24 @@ class NativeEntryViewerTest {
repeat(50) { directory -> repeat(50) { directory ->
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}") val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
assertTrue(folder.mkdirs()) 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 storage = session.storage()
val navigateStart = android.os.SystemClock.elapsedRealtime() val navigateStart = android.os.SystemClock.elapsedRealtime()
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size) assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
assertEquals( assertEquals(
20, 200,
storage.passwordNavigation("load/slot-000", null, null, false).rows.size storage.passwordNavigation("load/slot-000", null, null, false).rows.size
) )
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart 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() val searchStart = android.os.SystemClock.elapsedRealtime()
assertEquals(1000, storage.passwordNameSearch("load").size) assertEquals(10000, storage.passwordNameSearch("load").size)
assertEquals(20, storage.passwordNameSearch("load/slot-019").size) assertEquals(200, storage.passwordNameSearch("load/slot-019").size)
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
assertTrue("1000-entry name search must finish in 5 s", searchMs < 5000) assertTrue("10000-entry name search must finish in 5 s", searchMs < 5000)
performance("tree_1000_navigation_ms", navigateMs) performance("tree_10000_navigation_ms", navigateMs)
performance("tree_1000_search_ms", searchMs) performance("tree_10000_search_ms", searchMs)
} }
} finally { } finally {
withContext(Dispatchers.Main.immediate) { repository.lock() } 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions 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.CircularProgressIndicator
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -15,6 +16,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -108,7 +110,7 @@ internal fun PassphraseDialog(
} }
DisposableEffect(Unit) { onDispose { secret = "" } } DisposableEffect(Unit) { onDispose { secret = "" } }
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { onDismissRequest = {
secret = "" secret = ""
@@ -116,7 +118,10 @@ internal fun PassphraseDialog(
}, },
title = { Text(title) }, title = { Text(title) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(
Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ProtectedInputSurface(presentation) ProtectedInputSurface(presentation)
Text(detail) Text(detail)
OutlinedTextField( OutlinedTextField(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -45,108 +46,111 @@ fun CredentialSelectionScreen(
onConfirm: () -> Unit, onConfirm: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
LazyColumn( HingeSafeLayout {
Modifier.fillMaxSize().padding(24.dp), LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp) 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) {
item { 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( Text(
stringResource( stringResource(
if (busy) { if (saving) R.string.provider_save_title else R.string.provider_fill_title
R.string.provider_working ),
} else if (saving) { style = MaterialTheme.typography.headlineSmall
R.string.provider_save
} else {
R.string.provider_fill
}
)
) )
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 onChange: (Boolean) -> Unit
) { ) {
Row( Row(
Modifier.fillMaxWidth().toggleable( Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
value = checked, value = checked,
enabled = enabled, enabled = enabled,
role = Role.Checkbox, role = Role.Checkbox,
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -287,11 +288,16 @@ fun EditorDiscardDialog(repository: EditorRepository) {
val state by repository.state.collectAsStateWithLifecycle() val state by repository.state.collectAsStateWithLifecycle()
if (!state.discardRequested) return if (!state.discardRequested) return
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = repository::keepEditing, onDismissRequest = repository::keepEditing,
title = { Text(stringResource(R.string.editor_discard_title)) }, 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 = { confirmButton = {
TextButton(onClick = repository::confirmDiscard) { TextButton(onClick = repository::confirmDiscard) {
Text(stringResource(R.string.editor_discard)) Text(stringResource(R.string.editor_discard))
@@ -320,7 +326,7 @@ private fun EditorAddDialog(
var name by rememberProtectedInput(authentication.presentation) var name by rememberProtectedInput(authentication.presentation)
var value by rememberProtectedInput(authentication.presentation) var value by rememberProtectedInput(authentication.presentation)
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close, onDismissRequest = close,
title = { title = {
@@ -338,7 +344,7 @@ private fun EditorAddDialog(
}, },
text = { text = {
ProtectedInputSurface(authentication.presentation) ProtectedInputSurface(authentication.presentation)
Column { Column(Modifier.verticalScroll(rememberScrollState())) {
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (kind == if (kind ==
EditorFieldKind.Field EditorFieldKind.Field
@@ -399,12 +405,12 @@ private fun EditorGenerateDialog(
var noSymbols by remember { mutableStateOf(false) } var noSymbols by remember { mutableStateOf(false) }
val noSymbolsLabel = stringResource(R.string.editor_no_symbols) val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close, onDismissRequest = close,
title = { Text(stringResource(R.string.editor_generate)) }, title = { Text(stringResource(R.string.editor_generate)) },
text = { text = {
Column { Column(Modifier.verticalScroll(rememberScrollState())) {
Text(stringResource(R.string.editor_generate_detail)) Text(stringResource(R.string.editor_generate_detail))
OutlinedTextField( OutlinedTextField(
length, length,
@@ -466,7 +472,7 @@ internal fun EditorCreationDialog(
) { ) {
var name by rememberProtectedInput(authentication.presentation) var name by rememberProtectedInput(authentication.presentation)
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close, onDismissRequest = close,
title = { title = {
@@ -12,7 +12,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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)) } TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
if (edit) { if (edit) {
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
onDismissRequest = { edit = false }, onDismissRequest = { edit = false },
title = { Text(stringResource(R.string.entry_edit)) }, title = { Text(stringResource(R.string.entry_edit)) },
text = { text = {
@@ -215,12 +214,12 @@ fun EntryDialogs(
} }
} }
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
onDismissRequest = repository::dismissEdit, onDismissRequest = repository::dismissEdit,
title = { Text(edit.label) }, title = { Text(edit.label) },
text = { text = {
CompositionLocalProvider(LocalClipboard provides clipboard) { CompositionLocalProvider(LocalClipboard provides clipboard) {
Column { Column(Modifier.verticalScroll(rememberScrollState())) {
ProtectedInputSurface(authentication.presentation) ProtectedInputSurface(authentication.presentation)
OutlinedTextField( OutlinedTextField(
value, 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -208,7 +209,7 @@ fun HomeScreen(
} }
if (committing) { if (committing) {
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
onDismissRequest = { onDismissRequest = {
committing = false committing = false
message = "" message = ""
@@ -216,7 +217,7 @@ fun HomeScreen(
title = { Text(stringResource(R.string.home_commit_title)) }, title = { Text(stringResource(R.string.home_commit_title)) },
text = { text = {
ProtectedInputSurface() ProtectedInputSurface()
Column { Column(Modifier.verticalScroll(rememberScrollState())) {
Text(state.name) Text(state.name)
Text(state.email) Text(state.email)
OutlinedTextField( OutlinedTextField(
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
@@ -67,7 +66,7 @@ internal fun MutationDialog(
if (lease.busy) authentication.cancel() if (lease.busy) authentication.cancel()
} }
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = cancel, onDismissRequest = cancel,
title = { Text(stringResource(plan.action.label(plan.kind))) }, title = { Text(stringResource(plan.action.label(plan.kind))) },
@@ -112,7 +111,9 @@ internal fun MutationDialog(
) )
) )
Row( Row(
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) { Modifier.fillMaxWidth().heightIn(
min = 48.dp
).toggleable(discard, role = Role.Checkbox) {
discard = it discard = it
} }
) { ) {
@@ -127,7 +128,7 @@ internal fun MutationDialog(
if (plan.action != PasswordMutation.Delete) { if (plan.action != PasswordMutation.Delete) {
items(plan.destinations, key = { it.path }) { candidate -> items(plan.destinations, key = { it.path }) { candidate ->
Row( Row(
Modifier.fillMaxWidth().padding( Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(
start = ( start = (
candidate.depth.coerceAtMost(12u).toInt() * candidate.depth.coerceAtMost(12u).toInt() *
12 12
@@ -162,7 +163,7 @@ internal fun MutationDialog(
Text(stringResource(R.string.mutation_collision)) Text(stringResource(R.string.mutation_collision))
target.collisionTargets.forEach { Text(it) } target.collisionTargets.forEach { Text(it) }
Row( Row(
Modifier.fillMaxWidth().toggleable( Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
overwrite, overwrite,
role = Role.Checkbox role = Role.Checkbox
) { ) {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
@@ -127,7 +128,7 @@ fun OnboardingScreen(
Text(stringResource(R.string.setup_choose_key)) Text(stringResource(R.string.setup_choose_key))
state.keys.forEach { item -> state.keys.forEach { item ->
Row( Row(
Modifier.selectable( Modifier.heightIn(min = 48.dp).selectable(
selected = selectedKey == item.fingerprint, selected = selectedKey == item.fingerprint,
role = Role.RadioButton, role = Role.RadioButton,
onClick = { key = item.fingerprint } onClick = { key = item.fingerprint }
@@ -253,7 +254,7 @@ fun OnboardingScreen(
"branch" -> { "branch" -> {
state.branches.forEach { item -> state.branches.forEach { item ->
Row( Row(
Modifier.selectable( Modifier.heightIn(min = 48.dp).selectable(
selected = selectedBranch == item, selected = selectedBranch == item,
role = Role.RadioButton, role = Role.RadioButton,
onClick = { branch = item } onClick = { branch = item }
@@ -268,7 +269,9 @@ fun OnboardingScreen(
it it
} }
Row( Row(
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = { Modifier.heightIn(
min = 48.dp
).toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
reuse = reuse =
it it
}) })
@@ -4,8 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.TextAutoSize 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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -138,11 +139,16 @@ internal fun OtpCodeView(
} }
if (confirmation) { if (confirmation) {
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { confirmation = false }, onDismissRequest = { confirmation = false },
title = { Text(stringResource(R.string.otp_generate_hotp)) }, 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 = { confirmButton = {
TextButton(enabled = !busy, onClick = { TextButton(enabled = !busy, onClick = {
confirmation = false confirmation = false
@@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -432,14 +433,14 @@ fun PasswordScreen(
if (actions && state.selected != null && !state.loading) { if (actions && state.selected != null && !state.loading) {
val selected = requireNotNull(state.selected) val selected = requireNotNull(state.selected)
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
onDismissRequest = { actions = false }, onDismissRequest = { actions = false },
title = { title = {
Text(selected.title) Text(selected.title)
}, },
text = { text = {
ProtectedInputSurface() ProtectedInputSurface()
Column { Column(Modifier.verticalScroll(rememberScrollState())) {
Text(selected.detail + "\n" + selected.path) Text(selected.detail + "\n" + selected.path)
PasswordMutation.entries.forEach { action -> PasswordMutation.entries.forEach { action ->
TextButton(onClick = { TextButton(onClick = {
@@ -44,7 +44,8 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
val view = LocalView.current.rootView val view = LocalView.current.rootView
val surface = remember(view) { protectedSurface(view) } val surface = remember(view) { protectedSurface(view) }
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) } 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() val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
DisposableEffect(presentation, view) { DisposableEffect(presentation, view) {
val registration = presentation.register(surface::cover) 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) val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
SideEffect { SideEffect {
if (revealCleanContent) { if (revealCleanContent && generation == presentation.generation.value) {
surface.uncover() surface.uncover()
} else { } else {
surface.cover() surface.cover()
@@ -8,13 +8,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -308,7 +308,7 @@ fun SettingsScreen(
resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() } resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() }
} }
} }
AlertDialog( HingeSafeAlertDialog(
onDismissRequest = { onDismissRequest = {
licenses = false licenses = false
}, },
@@ -358,7 +358,7 @@ private fun SettingsEditDialog(
timeout = state.page?.timeout?.toString().orEmpty() timeout = state.page?.timeout?.toString().orEmpty()
} }
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = close, onDismissRequest = close,
title = { title = {
@@ -455,7 +455,7 @@ internal fun KeyTransferDialog(
} }
} }
ProtectedWindow() ProtectedWindow()
AlertDialog( HingeSafeAlertDialog(
properties = DialogProperties( properties = DialogProperties(
securePolicy = SecureFlagPolicy.SecureOn, securePolicy = SecureFlagPolicy.SecureOn,
usePlatformDefaultWidth = false usePlatformDefaultWidth = false
@@ -475,7 +475,7 @@ internal fun KeyTransferDialog(
if (!state.transfer.setup) { if (!state.transfer.setup) {
state.transfer.keys.forEach { key -> state.transfer.keys.forEach { key ->
Row( Row(
Modifier.fillMaxWidth().selectable( Modifier.fillMaxWidth().heightIn(min = 48.dp).selectable(
selected = fingerprint == key.fingerprint, selected = fingerprint == key.fingerprint,
enabled = !state.busy, enabled = !state.busy,
role = Role.RadioButton, role = Role.RadioButton,
@@ -675,7 +675,7 @@ internal fun KeyTransferDialog(
@Composable @Composable
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) { private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
Row( Row(
Modifier.fillMaxWidth().toggleable( Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
value = checked, value = checked,
role = Role.Checkbox, role = Role.Checkbox,
onValueChange = change onValueChange = change
@@ -1,7 +1,6 @@
package de.rfc1437.ironstorage.features package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -78,7 +77,7 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
listOf(route) listOf(route)
} }
// Actual window bounds, rather than device type, also handle desktop and split-screen resize. // Actual window bounds, rather than device type, also handle desktop and split-screen resize.
BoxWithConstraints(Modifier.fillMaxSize()) { HingeSafeLayout {
val rail = maxWidth >= 600.dp val rail = maxWidth >= 600.dp
val largeFont = LocalDensity.current.fontScale >= 1.6f val largeFont = LocalDensity.current.fontScale >= 1.6f
Scaffold( 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", "Searchable settings must finish loading",
device.wait(Until.findObject(By.text("Search settings")), 10000) 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 package de.rfc1437.ironstorage.platform
import android.os.Build
import android.security.keystore.KeyInfo import android.security.keystore.KeyInfo
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.filters.SdkSuppress import androidx.test.filters.SdkSuppress
@@ -35,7 +36,7 @@ class AndroidKeystoreTest {
) = KeystoreKeySpecification( ) = KeystoreKeySpecification(
alias, alias,
auth, auth,
true, Build.VERSION.SDK_INT >= 28,
auth == KeystoreAuthorization.StrongBiometric, auth == KeystoreAuthorization.StrongBiometric,
false 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() { @Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() {
val os = AndroidKeystoreAdapter(context) val os = AndroidKeystoreAdapter(context)
assertFalse(os.deviceState().locked) assertFalse(os.deviceState().locked)
@@ -2,16 +2,51 @@ package de.rfc1437.ironstorage.platform
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class AndroidQrCameraTest { 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() { @Test fun livePreviewDeliversBoundedPlanesAndCanBeClosedAndReopened() {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals( assertEquals(
+170 -89
View File
@@ -1,108 +1,189 @@
# Android hardening # Android hardening
The Android presentation layer uses resource strings for Kotlin-owned notices, Kotlin-owned notices, action labels, counts and durations use Android resources.
action labels, counts, and durations. Rust-owned presentation text remains opaque: Rust-owned presentation text stays opaque; frontends do not parse or translate it.
frontends do not parse or translate it. Repeated editor actions include the field Screen tests run Compose accessibility checks, including the finished mutation,
label, and hidden editor values are checked against the actual Android accessibility camera, provider and TOTP screens. Hidden editor/viewer values are also checked
node tree as well as Compose semantics. Compose accessibility checks run before against Android's actual accessibility node tree. Explicit buttons and semantic
interactions in every app screen test. actions provide alternatives to swipe, reorder and pull-to-refresh gestures.
Consent and selection rows have at least 48 dp touch targets.
Blank name searches do not traverse the password store. QR luminance inputs reject The shell, provider selection and all modal dialogs avoid separating/occluding
zero or greater-than-4096 dimensions before allocating the decoder image; owned hinges. Book posture uses the largest unobstructed pane; tabletop uses the upper
decoded luminance bytes are zeroized. License loading runs on the IO dispatcher. pane. Bounds are in window coordinates, offsets remain correct in RTL, and insets
Editor keystrokes coalesce encrypted recovery checkpoints for 150 ms; save, already removed by the pane are consumed before applying system-bar/IME padding.
reorder, and recovery wait for the latest checkpoint. This changes persistence Dialog titles, forms and actions can scroll at large font sizes; form state stays
scheduling, not authorization or the interactive unlock boundary. composed while scrolling. Regressions exercise live flat/book/tabletop changes,
preserve an entered commit message, check action bounds against the hinge, and
reach every generator control at 200% text before submitting its entered length.
Blank name searches do not traverse the store. QR luminance inputs reject zero or
greater-than-4096 dimensions before allocation and owned decoded bytes are
zeroized. License loading runs on IO. Editor input coalesces encrypted recovery
checkpoints for 150 ms; save/reorder/recovery wait for the latest checkpoint.
Interactive authorization remains in Rust and is never bypassed to hide latency.
Foreground cleanup synchronously masks protected surfaces and clears registered
inputs. The parent composition observes the cleanup generation before rebuilding
content; its reveal effect checks the latest generation again before exposing the
clean foreground. This fixes a permanent black surface after failed authentication
and tab navigation. The regression was observed failing before the fix, and
computer use verified failed unlock followed by usable Search in the final build.
## Reproducible gates
From the repository root, run the four required Cargo checks and:
```sh
CARGO=/opt/homebrew/opt/rustup/bin/cargo python3 .gitea/scripts/android_ci.py test
CARGO=/opt/homebrew/opt/rustup/bin/cargo python3 .gitea/scripts/android_ci.py build
CARGO=/opt/homebrew/opt/rustup/bin/cargo python3 .gitea/scripts/android_ci.py emulator <serial>
CARGO=/opt/homebrew/opt/rustup/bin/cargo python3 .gitea/scripts/android_ci.py performance <serial>
```
The emulator gates check ARM ABI, emulator identity, unlocked credential storage,
actual keyguard state and wakefulness before installing or invoking tests. Run
one emulator suite at a time. It reports completion only after the runner returns
success. The completed harness and target processes are stopped even on failure
or timeout, retaining application data. ADB calls have a 15-minute ceiling.
Platform coverage includes real CameraX frames, background/resume and
Keystore round-trip/tamper behavior, including rejection of an unsupported
unlocked-device key flag on API 26. Temporary test camera permission is restored.
Preserve each AVD and install updates over the same package. After a crash, stop
the affected instrumentation, cold boot the same AVD without wiping data, unlock
it and verify the visible app before repeating validation. A runner process alone
is not evidence of progress. The 2026-10-15 results below use fresh post-restart
runs; preliminary results from before the emulator restart are excluded.
## Native journey budgets ## Native journey budgets
The `android_ci.py emulator <serial>` gate requires exactly one numeric measurement The gate rejects missing, duplicate, malformed or over-budget measurements. Raw
for each journey below and rejects missing, duplicate, malformed, or over-budget fixture output stays local; published reports retain reviewed numbers and status.
values. Raw fixture output remains local; published reports contain numeric values The tree fixture contains 10,000 encrypted files in 50 folders, replicated from
and test status only. The large-tree fixture contains 1,000 valid encrypted entries Rust-produced public fixture ciphertext. Cached
in 50 folders. These timings measure native/repository work, not rendered frame TOTP/provider preparation uses 100 catalogued encrypted entries while locked,
latency or a physical device. with 10 warmups and 30 samples per p95 measurement. Authenticated fill separately
uses real Rust/GPG decryption and explicit consent.
| Journey | Required budget | API 26 ARM64 / 4 KB | API 37 ARM64 / strict 16 KB | | Native journey | Budget | API 26 / 4 KB | API 37 / 4 KB | API 37 / strict 16 KB |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: | ---: |
| 100 editor input updates | < 500 ms | 4 ms | 6 ms | | 100 editor input updates | < 500 ms | 4 ms | 6 ms | 12 ms |
| Editor update p95 | < 32,000 μs | 61 μs | 102 μs | | Editor update p95 | < 32,000 us | 44 us | 70 us | 231 us |
| TOTP discovery cache rebuild | < 15,000 ms | 1,924 ms | 1,861 ms | | TOTP discovery cache rebuild | < 15,000 ms | 1,831 ms | 1,892 ms | 2,516 ms |
| QR image analysis | < 5,000 ms | 99 ms | 91 ms | | QR image analysis | < 5,000 ms | 86 ms | 93 ms | 122 ms |
| 1,000-entry tree navigation | < 3,000 ms | 11 ms | 11 ms | | 10,000-entry tree navigation | < 3,000 ms | 75 ms | 95 ms | 121 ms |
| 1,000-entry name search | < 5,000 ms | 27 ms | 26 ms | | 10,000-entry name search | < 5,000 ms | 228 ms | 281 ms | 355 ms |
| Cached TOTP open p95 | < 1,000,000 us | 5,059 us | 8,574 us | 5,322 us |
| Warm TOTP refresh p95 | < 3,000,000 us | 7,353 us | 10,509 us | 7,890 us |
| Locked provider preparation p95 | < 1,000,000 us | 3,775 us | 6,607 us | 4,155 us |
| Authenticated provider fill | < 5,000 ms | 1,212 ms | 1,278 ms | 1,247 ms |
Both configurations reported `OK (31 tests)` for app instrumentation and passed All three configurations passed app/core/bridge/platform instrumentation and five
the native core journey and bridge suite. Opt-in inspection and biometric tests real debug cold starts after the emulator restart. Strict 16 KB validation disabled
remain assumption-skipped unless explicitly requested; these runs do not establish the bionic/PackageManager 4 KB application compatibility path. Its five native-load
their manual coverage. Native load samples ranged from 32.7–35.2 ms on API 26 and samples were 66.4-89.7 ms and debug activity starts were 836-932 ms.
63.3–78.7 ms on API 37 with strict 16 KB pages. Five debug cold starts ranged from
202–279 ms and 708–1,086 ms respectively. These debug activity timings are separate
from release Macrobenchmark startup measurements.
## Adaptive checks These are native/repository timings on emulators, not rendered-frame latency or
physical-device performance. The API 37 / 4 KB authenticated-fill figure comes
from a separate targeted native test after its full suite. Opt-in inspection and
biometric tests remain assumption-skipped during ordinary instrumentation; their
presence in the runner's reported test count does not establish manual coverage.
After the foreground-cleanup fix, the final app suite was repeated on all three
configurations: each reported 53 tests with no failures.
`Tier2WindowTest` exercises searchable Preferences and keyboard activation using ## Adaptive and environment checks
actual window dimensions. The API 26 emulator passed compact (411 dp), medium
(640 dp), expanded (1,067 dp), 200% font with RTL, landscape with 200% font and RTL,
and a smaller window with 200% font. Size, density, font scale, and rotation settings
were restored after the run. This covers window geometry; it does not establish
hinge occlusion or folded/tabletop posture behavior.
The shell consumes IME insets, and the activity explicitly requests `adjustResize`. On 2026-10-15 API 26 passed actual compact (411 dp), medium (640 dp), expanded
The optional `hardeningIme=true` instrumentation argument opens a real keyboard (1,067 dp), 200% text with RTL, landscape with 200% text and RTL, and smaller-window
and checks shell content bounds against the platform IME inset. It reproduced an checks. The real keyboard opened and shell bounds ended above its IME inset.
overlap before the shared-shell fix and passes afterward on API 26 and API 37 with Live resize from 640 to 450 dp preserved a query entered into the actual Rust
strict 16 KB pages. Temporarily enable `show_ime_with_hard_keyboard` for this check search repository across activity recreation, followed by layout/keyboard checks.
and restore its original value afterward. Full app instrumentation also reported All size, density, font, rotation and IME settings were restored.
`OK (31 tests)` with `hardeningRtl=true` on the strict 16 KB image.
## Release benchmarks The Pixel Fold AVD passed unfolded, half-open book, half-open tabletop, folded
cover and tabletop with a real IME. The controller waits for the active inner/cover
display before setting rotation, then verifies the committed posture and rotation;
issuing rotation during the cover-to-inner switch previously left the keyboard
case on the wrong layout. Computer use also inspected the actual setup/key-selection
screens in tabletop posture. Original posture, physical rotation and IME settings
were restored.
`android_ci.py performance <serial>` installs the benchmark app over the retained The available ARM images do not advertise freeform/desktop support. An explicit
installation and records five cold and five warm startup iterations. The benchmark freeform launch on the foldable was accepted but fell back to fullscreen; it is
variant uses release code and shrinking, debug signing, and shell profiling; the not counted as desktop validation. Actual split-screen, live resize and expanded
distributed release variant does not enable profiling. `ReportDrawnWhen` waits for window geometry are covered. Desktop-specific system decorations remain an image
the shell to leave its native loading state. Compilation is preserved rather than availability limit; no x86_64 or physical-device coverage is implied.
resetting application data.
The startup gate requires five complete numeric initial/full-display samples for The old API 26 Pixel Launcher crashed during a tablet-size transition. The blocked
each mode, with cold startup below 5,000 ms and warm startup below 3,000 ms. attempt was stopped and its display overrides restored. Remaining cases passed
Before installing the generated profile, release-like median full-display times with Settings in front during transitions, avoiding that launcher defect. An
were 268.6 ms cold / 131.2 ms warm on API 26 and 1,203.5 ms cold / 427.0 ms warm on actual split-screen smoke check also showed IronStorage alongside Settings;
API 37. These emulator observations preserve existing compilation state and are setup controls remained reachable by scrolling and fullscreen was restored.
not a claim of profile speedup. Raw traces and device metadata stay local; CI
artifacts retain only API/ABI and the reviewed numeric samples.
`android_ci.py profiles <serial>` generates a profile from startup and the existing The current phone passed the full app suite with 200% system text, RTL, high text
Preferences journey. Generation requires API 34 or newer so compilation reset contrast, grayscale and all three animation scales at zero. A focused follow-up
does not fall back to reinstalling the app on older non-rooted images. Inspect the also covered the final scrolling dialogs and tabletop generator. The native
generated local artifacts before promoting them into the app source set. Generation cached-catalog/provider fixture passed under forced deep doze (still IDLE after
uses a non-obfuscated `profile` variant, while startup measurements use the shrunk the run), battery saver, disabled Wi-Fi/mobile data, and Android's forced-low
`benchmark` variant. Both use release source code and debug signing. storage notification. A separate native cached-catalog/provider run passed on connected metered Wi-Fi;
Settings showed Metered and the active Wi-Fi network lacked NOT_METERED. Automatic
network usage was restored afterward. These network checks exercise local cached
work, not an unconfigured remote synchronization service.
Every modified setting was restored. This is storage-low
notification coverage, not a destructive attempt to fill the emulator filesystem.
Rust transaction/checkpoint tests separately cover failing writes and rollback.
App tests cover memory-pressure masking, saved-state redaction and recreation;
five real process cold starts additionally exercise retained app storage.
The committed `app/src/main/baseline-prof.txt` was generated on the API 37 ARM64 Computer use authenticated and decrypted the retained public provider fixture on
emulator from the startup/Preferences journey on 2026-10-11, with 22,331 rules and strict-16-KB API 37 using its source-defined passphrase. Its six Git commits and
SHA-256 `687afdc199dacf0736dc1c577767dea9b81ddc987b5b61b02b5afed5e501b503`. clean worktree survived install-over updates. The password stayed Hidden with
Regenerate it with the `profiles` command after changing those journeys; use the explicit Reveal/Copy/Edit/Lock controls. TalkBack was enabled through Android
untimestamped `BaselineProfile_startupAndPreferences-baseline-prof.txt` output, Studio and verified as a bound service with touch exploration active. Computer
check that application class names are unobfuscated, and rebuild the release APK use focused the native Hidden value and activated/cancelled the native editor
to verify its compiled `assets/dexopt/baseline.prof` remains within ART's size limit. through TalkBack. This is separate from Compose checks, whose UI automation
normally suppresses accessibility services. Spoken audio was not verified.
## Completion boundary Switch Access was enabled through its launcher and Android Settings. Its USB
setup reported no connected device; manual Next assignment did not capture the
emulator's volume keys or forwarded host keyboard. No scanning result is claimed.
This is a limit of the current emulator/input setup; keyboard/D-pad and semantic-action
coverage remain in the presentation suite. TalkBack and Switch Access were
restored to off, and no switch assignments were created.
Issue #105 explicitly depends on every Android feature UI issue. CameraX (#106), The other retained phone vault rejected its historical public test passphrase;
TOTP list/detail (#107), mutation workflows (#108), Credential Manager/Autofill that journey is not a successful manual unlock. Rejection and subsequent Search
(#109), and TOTP discovery UI (#110) are still open. Their final screen accessibility, navigation did verify the foreground-cleanup fix. No credential or vault reset
camera interruption, cached-open/refresh presentation, and provider response was performed. The separately source-defined provider fixture above supplies the
latency cannot be certified by tests of the existing screens. Keep #105 open until successful native decryption journey.
those implementations and the remaining environment/posture checks are complete.
ARMv7 is package-validated; the installed runnable emulator images are ARM64.
On 2026-10-11 the user explicitly requested committing the independent implementation ## Release startup and profiles
now and delaying final coverage. Issues #106–#110 received `critical`; resume those
eligible Android enhancements in numeric order, then revisit #105. Remaining `performance` installs the shrunk benchmark variant over the retained app. It uses
checks include TalkBack/Switch Access and contrast/motion modes across the finished release source, debug signing and shell profiling; distributed release builds do
screens, real hinge/posture and live-resize coverage, and low-memory, doze, battery, not enable profiling. `ReportDrawnWhen` waits for native shell loading to finish.
network, camera-interruption, and storage-pressure scenarios. Do not close #105 CompilationMode.Ignore preserves compilation and application data. Five complete
based solely on the current instrumentation and window-geometry results. initial/full-display samples are required for each startup mode; every cold sample
must be below 5,000 ms and every warm sample below 3,000 ms.
The fresh API 26 full-display samples were 191.0-216.4 ms cold and 56.5-84.2 ms
warm. The current strict-16-KB API 37 samples were 246.4-475.9 ms cold and
78.5-139.3 ms warm. These are observations with retained compilation, not a profile-speedup
comparison.
`profiles <serial>` requires API 34 or newer, avoiding the older non-rooted
benchmark library fallback that can reinstall the app. The unshrunk profile
variant records startup, searchable Preferences, Passwords, Search and cached
TOTP navigation. Inspect generated application names before promoting the
untimestamped `BaselineProfile_startupAndPreferences-baseline-prof.txt`; rebuild
the release APK and verify compiled `assets/dexopt/baseline.prof` size afterward.
The committed profile was regenerated on API 37 ARM64 / strict 16 KB on
2026-10-15. It has 24,647 rules, including 2,826 application rules, and SHA-256
`947599f29c326a02a55bab8f12ac5cff3743560319993424667ec864fc862929`.
The release APK contains an 10,148-byte `baseline.prof` and 1,376-byte
`baseline.profm`, both checked after the final package build.
ARMv7 is compiled/package-audited. Available runnable images are ARM64; x86_64
and physical-device validation are outside the user-directed milestone scope.