Complete Android adaptive and accessibility hardening (#105)
This commit is contained in:
1 parent
f4a6edbc68
commit
45d78aa5b3
33 files changed
+4500
-1280
No files matched your search
@@ -251,8 +251,12 @@ PERFORMANCE_BUDGETS = {
|
||||
"editor_update_p95_us": 32000,
|
||||
"cache_rebuild_ms": 15000,
|
||||
"qr_analysis_ms": 5000,
|
||||
"tree_1000_navigation_ms": 3000,
|
||||
"tree_1000_search_ms": 5000,
|
||||
"totp_cached_open_p95_us": 1000000,
|
||||
"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
|
||||
|
||||
|
||||
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):
|
||||
adb = SDK / "platform-tools/adb"
|
||||
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")
|
||||
if abi not in ABIS:
|
||||
raise RuntimeError("Only ARM emulators are in scope")
|
||||
if command("shell", "getprop", "ro.kernel.qemu") != "1":
|
||||
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"))
|
||||
try:
|
||||
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.
|
||||
suites = [("app", "de.rfc1437.ironstorage.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 = []
|
||||
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"))
|
||||
command("install", "-r", test_apk)
|
||||
if module == "rust-bridge":
|
||||
for _ in range(5):
|
||||
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=")]
|
||||
if len(samples) != 1 or "OK (1 test)" not in measured:
|
||||
raise RuntimeError("Native cold-load measurement failed")
|
||||
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:
|
||||
local = ROOT / "android/build/local-validation"
|
||||
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)")
|
||||
if module == "core":
|
||||
journey_timings = performance_metrics(result)
|
||||
# Release instrumentation-owned activities and native owners before real app starts.
|
||||
command("shell", "am", "force-stop", package)
|
||||
print(f"ARM API {api}, {pages}-byte pages: {module} instrumentation passed", flush=True)
|
||||
# No activity state or application data is deleted between process recreation runs.
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
@@ -364,11 +405,15 @@ def startup_metrics(data):
|
||||
def performance(serial, profiles=False):
|
||||
adb = SDK / "platform-tools/adb"
|
||||
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")
|
||||
api = int(command("shell", "getprop", "ro.build.version.sdk"))
|
||||
if abi not in ABIS or command("shell", "getprop", "ro.kernel.qemu") != "1":
|
||||
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:
|
||||
raise RuntimeError("Data-preserving baseline generation requires API 34 or newer")
|
||||
variant = "profile" if profiles else "benchmark"
|
||||
@@ -379,15 +424,16 @@ def performance(serial, profiles=False):
|
||||
directory = "/sdcard/Android/media/" + harness + "/additional_test_output"
|
||||
command("shell", "mkdir", "-p", directory)
|
||||
test = "BaselineProfile" if profiles else "StartupBenchmark"
|
||||
result = command("shell", "am", "instrument", "-w", "-r", "-e", "class", harness + "." + test,
|
||||
"-e", "additionalTestOutputDir", directory, "-e", "androidx.benchmark.suppressErrors", "EMULATOR",
|
||||
harness + "/androidx.test.runner.AndroidJUnitRunner")
|
||||
print(f"ARM API {api}: invoking {test} instrumentation", flush=True)
|
||||
result = instrumentation(command, harness, ("-e", "class", harness + "." + test,
|
||||
"-e", "additionalTestOutputDir", directory,
|
||||
"-e", "androidx.benchmark.suppressErrors", "EMULATOR"),
|
||||
"de.rfc1437.ironstorage")
|
||||
local = ROOT / "android/build/local-validation" / ("performance-" + serial)
|
||||
local.mkdir(parents=True, exist_ok=True)
|
||||
local.chmod(0o700)
|
||||
(local / (test + ".txt")).write_text(result)
|
||||
(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:
|
||||
raise RuntimeError("Performance instrumentation failed; raw output stays local")
|
||||
command("pull", directory, local)
|
||||
@@ -402,6 +448,26 @@ def performance(serial, profiles=False):
|
||||
|
||||
|
||||
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 data():
|
||||
return {"benchmarks": [{"name": name, "repeatIterations": 5, "metrics": {
|
||||
|
||||
+6
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -14,11 +15,16 @@ import de.rfc1437.ironstorage.core.CredentialCandidate
|
||||
import de.rfc1437.ironstorage.core.ProviderRequest
|
||||
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialSelectionScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
private var selected by mutableStateOf<String?>(null)
|
||||
private var destination by mutableStateOf("")
|
||||
private var consent by mutableStateOf(false)
|
||||
|
||||
@@ -2,8 +2,12 @@ package de.rfc1437.ironstorage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.HingeInfo
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -20,10 +24,13 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
@@ -35,6 +42,7 @@ import de.rfc1437.ironstorage.core.EditorState
|
||||
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||
import de.rfc1437.ironstorage.features.EditorDiscardDialog
|
||||
import de.rfc1437.ironstorage.features.EntryEditorScreen
|
||||
import de.rfc1437.ironstorage.features.HingeSafeLayout
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -50,6 +58,55 @@ class EntryEditorScreenTest {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
@Test fun tabletopGeneratorKeepsEveryControlReachableAtLargeFont() {
|
||||
val repository = Fixture()
|
||||
val authentication = object : AuthenticationRepository by compose.activity.authentication {
|
||||
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
|
||||
}
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
BoxWithConstraints {
|
||||
val density = LocalDensity.current
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val height = with(density) { maxHeight.toPx() }
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||
MaterialTheme {
|
||||
HingeSafeLayout(
|
||||
Posture(
|
||||
true,
|
||||
listOf(
|
||||
HingeInfo(
|
||||
Rect(
|
||||
0f,
|
||||
height * .5f,
|
||||
width,
|
||||
height * .5f + 12f
|
||||
),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
)
|
||||
)
|
||||
)
|
||||
) { EntryEditorScreen(repository, authentication) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("entry-editor")
|
||||
.performScrollToNode(hasText("Generate password"))
|
||||
compose.onNodeWithText("Generate password").performClick()
|
||||
compose.onNodeWithText("Password length").performScrollTo().performTextReplacement("24")
|
||||
compose.onNodeWithTag("hinge-dialog-content").performTouchInput { swipeUp() }
|
||||
compose.onNodeWithText("Use letters and numbers only").performScrollTo()
|
||||
compose.onNodeWithText("Use letters and numbers only").assertIsDisplayed()
|
||||
compose.onNodeWithText("Generate replacement").performScrollTo().performClick()
|
||||
assertEquals(1, repository.generations)
|
||||
assertEquals(24u, repository.length)
|
||||
}
|
||||
|
||||
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
|
||||
val repository = Fixture()
|
||||
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.HingeInfo
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import de.rfc1437.ironstorage.core.HomeActionKind
|
||||
import de.rfc1437.ironstorage.features.HingeSafeLayout
|
||||
import de.rfc1437.ironstorage.features.HomeScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class HingeWindowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun livePostureChangesKeepContentAndProtectedDialogOutsideTheHinge() {
|
||||
compose.enableAccessibilityChecks()
|
||||
val repository = HomeScreenTest.Fixture()
|
||||
var mode by mutableStateOf(0)
|
||||
var hinge = Rect.Zero
|
||||
var density = 1f
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
density = LocalDensity.current.density
|
||||
val width = maxWidth.value * density
|
||||
val height = maxHeight.value * density
|
||||
hinge = if (mode == 1) {
|
||||
Rect(width * .35f, 0f, width * .35f + 12f, height)
|
||||
} else {
|
||||
Rect(0f, height * .5f, width, height * .5f + 12f)
|
||||
}
|
||||
val posture = Posture(
|
||||
isTabletop = mode == 2,
|
||||
hingeList = if (mode == 0) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
HingeInfo(hinge, false, mode == 1, true, true)
|
||||
)
|
||||
}
|
||||
)
|
||||
MaterialTheme {
|
||||
HingeSafeLayout(posture) { HomeScreen(repository) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
|
||||
compose.onNodeWithText("Commit message").performTextInput("Public posture fixture")
|
||||
for (posture in listOf(1, 2, 0)) {
|
||||
compose.runOnIdle { mode = posture }
|
||||
if (posture != 0) {
|
||||
compose.onNodeWithText("Create commit").performScrollTo()
|
||||
}
|
||||
val action = compose.onNodeWithText("Create commit")
|
||||
action.assertIsDisplayed()
|
||||
if (posture != 0) {
|
||||
val bounds = action.getUnclippedBoundsInRoot()
|
||||
val pixels = Rect(
|
||||
bounds.left.value * density,
|
||||
bounds.top.value * density,
|
||||
bounds.right.value * density,
|
||||
bounds.bottom.value * density
|
||||
)
|
||||
assertTrue("Dialog action must avoid the hinge", !pixels.overlaps(hinge))
|
||||
if (posture == 2) assertTrue(pixels.bottom <= hinge.top)
|
||||
compose.onNodeWithText("Public posture fixture").performScrollTo()
|
||||
}
|
||||
compose.onNodeWithText("Public posture fixture")
|
||||
.assertTextContains("Public posture fixture")
|
||||
}
|
||||
compose.onNodeWithText("Create commit").performClick()
|
||||
assertEquals(
|
||||
HomeActionKind.Commit to "Public posture fixture",
|
||||
repository.requests.single()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
@@ -93,12 +92,12 @@ class HomeScreenTest {
|
||||
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
|
||||
val repository = Fixture()
|
||||
show(repository)
|
||||
compose.onRoot().performTouchInput { swipeDown() }
|
||||
compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
|
||||
compose.waitUntil(5000) { repository.refreshes == 1 }
|
||||
assertTrue(repository.requests.isEmpty())
|
||||
compose.onNodeWithText("Cancel").performClick()
|
||||
}
|
||||
private class Fixture(changes: Int = 1) : HomeRepository {
|
||||
internal class Fixture(changes: Int = 1) : HomeRepository {
|
||||
private val actions = HomeActionKind.entries.map {
|
||||
HomeAction(
|
||||
it,
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.PasswordDestination
|
||||
@@ -38,12 +40,17 @@ import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MutationWorkflowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
private fun display(fixture: Fixture) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
@@ -55,7 +62,10 @@ class MutationWorkflowTest {
|
||||
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
|
||||
val fixture = Fixture()
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
|
||||
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
|
||||
compose.onNodeWithText("Source").performTouchInput {
|
||||
if (rtl) swipeRight() else swipeLeft()
|
||||
}
|
||||
compose.waitUntil(5000) { fixture.state.value.mutation != null }
|
||||
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
|
||||
assertEquals(0, fixture.committed)
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
@@ -49,6 +50,43 @@ class PresentationSecurityTest {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
|
||||
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
|
||||
val dirty = mutableStateOf(true)
|
||||
val registration = presentation.register { dirty.value = false }
|
||||
try {
|
||||
compose.runOnIdle {
|
||||
compose.activity.setProtectedTestContent(presentation) {
|
||||
MaterialTheme {
|
||||
Text(
|
||||
if (dirty.value) "Disposable protected fixture" else "Clean foreground"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Disposable protected fixture").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
presentation.clear()
|
||||
assertEquals(
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
|
||||
compose.activity.window.decorView.importantForAccessibility
|
||||
)
|
||||
assertFalse(dirty.value)
|
||||
}
|
||||
compose.onNodeWithText("Clean foreground").assertIsDisplayed()
|
||||
compose.onNodeWithText("Disposable protected fixture").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
assertFalse(
|
||||
"The rebuilt foreground surface must be visible to accessibility",
|
||||
compose.activity.window.decorView.importantForAccessibility ==
|
||||
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
registration.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
|
||||
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
|
||||
val done = CountDownLatch(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isHeading
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
@@ -28,9 +29,7 @@ class ShellScreenTest {
|
||||
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
|
||||
ShellTab.entries.forEach { tab ->
|
||||
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
|
||||
compose.onNode(
|
||||
hasText(page.title) and androidx.compose.ui.test.isSelectable()
|
||||
).performClick()
|
||||
compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
|
||||
compose.waitUntil(timeoutMillis = 10_000) {
|
||||
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
|
||||
.fetchSemanticsNodes().size == 1
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.input.key.Key
|
||||
@@ -20,6 +24,7 @@ import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -30,10 +35,12 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||
import de.rfc1437.ironstorage.features.ApplicationTheme
|
||||
import de.rfc1437.ironstorage.features.SearchScreen
|
||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||
import de.rfc1437.ironstorage.features.ShellScreen
|
||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -43,8 +50,32 @@ class Tier2WindowTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() {
|
||||
compose.runOnIdle {
|
||||
if (Build.VERSION.SDK_INT >= 27) compose.activity.setTurnScreenOn(true)
|
||||
val keyguard = compose.activity.getSystemService(KeyguardManager::class.java)
|
||||
if (!keyguard.isDeviceSecure) keyguard.requestDismissKeyguard(compose.activity, null)
|
||||
}
|
||||
compose.enableAccessibilityChecks()
|
||||
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
|
||||
if (InstrumentationRegistry.getArguments().getString("hardeningResize") == "true") {
|
||||
val initialWidth = compose.activity.window.decorView.width
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme { SearchScreen(original.search, {}) }
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Entry name or path").performTextReplacement("appearance")
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
Bundle().apply { putBoolean("ironstorage_resize_ready", true) }
|
||||
)
|
||||
compose.waitUntil(30000) { compose.activity.window.decorView.width != initialWidth }
|
||||
compose.runOnIdle {
|
||||
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
|
||||
assertEquals("appearance", current.search.state.value.query)
|
||||
current.search.clear()
|
||||
}
|
||||
}
|
||||
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||
val authentication = object : AuthenticationRepository by original.authentication {
|
||||
override val state = MutableStateFlow(AuthenticationState())
|
||||
@@ -72,7 +103,8 @@ class Tier2WindowTest {
|
||||
val width =
|
||||
compose.activity.window.decorView.width /
|
||||
compose.activity.resources.displayMetrics.density
|
||||
val navigation = if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
|
||||
val navigation = InstrumentationRegistry.getArguments().getString("expectedNavigation")
|
||||
?: if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
|
||||
compose.onNodeWithTag(navigation).assertIsDisplayed()
|
||||
compose.onNodeWithTag("settings-search").assertIsDisplayed()
|
||||
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onLast
|
||||
@@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -43,6 +45,10 @@ import org.junit.Test
|
||||
class TotpScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before fun enableChecks() {
|
||||
compose.enableAccessibilityChecks()
|
||||
}
|
||||
|
||||
private fun display(fixture: Fixture, fontScale: Float = 1f) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
|
||||
+3432
-1116
File diff suppressed because it is too large.
Load diff
+9
@@ -1,5 +1,7 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
@@ -109,6 +111,7 @@ class NativeCredentialProviderTest {
|
||||
emptyList()
|
||||
)
|
||||
}
|
||||
val fillStart = SystemClock.elapsedRealtime()
|
||||
val value = credentials.fill(
|
||||
request.token,
|
||||
caller,
|
||||
@@ -117,6 +120,12 @@ class NativeCredentialProviderTest {
|
||||
passphrase,
|
||||
emptyList()
|
||||
)
|
||||
val fillMs = SystemClock.elapsedRealtime() - fillStart
|
||||
assertTrue("Authenticated provider fill must finish within 5 s", fillMs < 5000)
|
||||
InstrumentationRegistry.getInstrumentation().sendStatus(
|
||||
0,
|
||||
Bundle().apply { putLong("ironstorage_provider_authenticated_fill_ms", fillMs) }
|
||||
)
|
||||
assertEquals("alice", value.username)
|
||||
assertEquals("fixture-password", value.password)
|
||||
assertEquals("CredentialValue([REDACTED])", value.toString())
|
||||
|
||||
+8
-8
@@ -270,24 +270,24 @@ class NativeEntryViewerTest {
|
||||
repeat(50) { directory ->
|
||||
val folder = File(vault, "load/slot-${directory.toString().padStart(3, '0')}")
|
||||
assertTrue(folder.mkdirs())
|
||||
repeat(20) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
|
||||
repeat(200) { entry -> File(folder, "entry-$entry.gpg").writeBytes(ciphertext) }
|
||||
}
|
||||
val storage = session.storage()
|
||||
val navigateStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(50, storage.passwordNavigation("load", null, null, false).rows.size)
|
||||
assertEquals(
|
||||
20,
|
||||
200,
|
||||
storage.passwordNavigation("load/slot-000", null, null, false).rows.size
|
||||
)
|
||||
val navigateMs = android.os.SystemClock.elapsedRealtime() - navigateStart
|
||||
assertTrue("1000-entry navigation must finish in 3 s", navigateMs < 3000)
|
||||
assertTrue("10000-entry navigation must finish in 3 s", navigateMs < 3000)
|
||||
val searchStart = android.os.SystemClock.elapsedRealtime()
|
||||
assertEquals(1000, storage.passwordNameSearch("load").size)
|
||||
assertEquals(20, storage.passwordNameSearch("load/slot-019").size)
|
||||
assertEquals(10000, storage.passwordNameSearch("load").size)
|
||||
assertEquals(200, storage.passwordNameSearch("load/slot-019").size)
|
||||
val searchMs = android.os.SystemClock.elapsedRealtime() - searchStart
|
||||
assertTrue("1000-entry name search must finish in 5 s", searchMs < 5000)
|
||||
performance("tree_1000_navigation_ms", navigateMs)
|
||||
performance("tree_1000_search_ms", searchMs)
|
||||
assertTrue("10000-entry name search must finish in 5 s", searchMs < 5000)
|
||||
performance("tree_10000_navigation_ms", navigateMs)
|
||||
performance("tree_10000_search_ms", searchMs)
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
|
||||
+126
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -2,8 +2,9 @@ package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,6 +16,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -108,7 +110,7 @@ internal fun PassphraseDialog(
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { secret = "" } }
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = {
|
||||
secret = ""
|
||||
@@ -116,7 +118,10 @@ internal fun PassphraseDialog(
|
||||
},
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ProtectedInputSurface(presentation)
|
||||
Text(detail)
|
||||
OutlinedTextField(
|
||||
|
||||
+6
-2
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -45,6 +46,7 @@ fun CredentialSelectionScreen(
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
HingeSafeLayout {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -116,7 +118,8 @@ fun CredentialSelectionScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
val recipient = request?.title ?: stringResource(R.string.provider_this_application)
|
||||
val recipient =
|
||||
request?.title ?: stringResource(R.string.provider_this_application)
|
||||
val confirmation = if (saving) {
|
||||
stringResource(
|
||||
R.string.provider_confirm_save
|
||||
@@ -149,6 +152,7 @@ fun CredentialSelectionScreen(
|
||||
TextButton(onClick = onCancel) { Text(stringResource(R.string.auth_cancel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -159,7 +163,7 @@ private fun ProviderConsent(
|
||||
onChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Checkbox,
|
||||
|
||||
+14
-8
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -287,11 +288,16 @@ fun EditorDiscardDialog(repository: EditorRepository) {
|
||||
val state by repository.state.collectAsStateWithLifecycle()
|
||||
if (!state.discardRequested) return
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = repository::keepEditing,
|
||||
title = { Text(stringResource(R.string.editor_discard_title)) },
|
||||
text = { Text(stringResource(R.string.editor_discard_detail)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.editor_discard_detail),
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = repository::confirmDiscard) {
|
||||
Text(stringResource(R.string.editor_discard))
|
||||
@@ -320,7 +326,7 @@ private fun EditorAddDialog(
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
var value by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
@@ -338,7 +344,7 @@ private fun EditorAddDialog(
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (kind ==
|
||||
EditorFieldKind.Field
|
||||
@@ -399,12 +405,12 @@ private fun EditorGenerateDialog(
|
||||
var noSymbols by remember { mutableStateOf(false) }
|
||||
val noSymbolsLabel = stringResource(R.string.editor_no_symbols)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = { Text(stringResource(R.string.editor_generate)) },
|
||||
text = {
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(stringResource(R.string.editor_generate_detail))
|
||||
OutlinedTextField(
|
||||
length,
|
||||
@@ -466,7 +472,7 @@ internal fun EditorCreationDialog(
|
||||
) {
|
||||
var name by rememberProtectedInput(authentication.presentation)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -73,7 +72,7 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
|
||||
TextButton(onClick = repository::lock) { Text(stringResource(R.string.password_lock)) }
|
||||
if (edit) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = { edit = false },
|
||||
title = { Text(stringResource(R.string.entry_edit)) },
|
||||
text = {
|
||||
@@ -215,12 +214,12 @@ fun EntryDialogs(
|
||||
}
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = repository::dismissEdit,
|
||||
title = { Text(edit.label) },
|
||||
text = {
|
||||
CompositionLocalProvider(LocalClipboard provides clipboard) {
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
ProtectedInputSurface(authentication.presentation)
|
||||
OutlinedTextField(
|
||||
value,
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
internal fun HingeSafeAlertDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
dismissButton: (@Composable () -> Unit)? = null,
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
text: (@Composable () -> Unit)? = null,
|
||||
properties: DialogProperties = DialogProperties()
|
||||
) {
|
||||
val posture = LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
|
||||
if (posture.hingeList.none { it.isSeparating || it.isOccluding }) {
|
||||
AlertDialog(
|
||||
onDismissRequest,
|
||||
confirmButton,
|
||||
dismissButton = dismissButton,
|
||||
title = title,
|
||||
text = text,
|
||||
properties = properties
|
||||
)
|
||||
return
|
||||
}
|
||||
Dialog(
|
||||
onDismissRequest,
|
||||
DialogProperties(
|
||||
dismissOnBackPress = properties.dismissOnBackPress,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
securePolicy = properties.securePolicy,
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
ProtectedInputSurface()
|
||||
HingeSafeLayout(posture) {
|
||||
val paneHeight = maxHeight
|
||||
Box(
|
||||
Modifier.fillMaxSize().systemBarsPadding().imePadding(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
Modifier.padding(16.dp).widthIn(max = 560.dp).fillMaxWidth()
|
||||
.heightIn(max = (paneHeight - 32.dp).coerceAtLeast(0.dp)),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(24.dp).verticalScroll(rememberScrollState())
|
||||
.testTag("hinge-dialog-content"),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
title?.let {
|
||||
Box(Modifier.semantics { heading() }) {
|
||||
ProvideTextStyle(MaterialTheme.typography.headlineSmall) {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
Box(
|
||||
Modifier.heightIn(
|
||||
max = (paneHeight - 80.dp).coerceAtLeast(0.dp)
|
||||
)
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.bodyMedium) { it() }
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
dismissButton?.invoke()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.adaptive.Posture
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal val LocalWindowPosture = staticCompositionLocalOf<Posture?> { null }
|
||||
|
||||
/** Keep tabletop content above the fold; otherwise use the largest unobscured pane. */
|
||||
@Composable
|
||||
fun HingeSafeLayout(
|
||||
posture: Posture? = null,
|
||||
content: @Composable BoxWithConstraintsScope.() -> Unit
|
||||
) {
|
||||
val current =
|
||||
posture ?: LocalWindowPosture.current ?: currentWindowAdaptiveInfoV2().windowPosture
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().onGloballyPositioned { origin = it.positionInWindow() }
|
||||
) {
|
||||
val bounds = with(density) { Rect(origin, Size(maxWidth.toPx(), maxHeight.toPx())) }
|
||||
val pane = hingeSafeBounds(bounds, current)
|
||||
val modifier = with(density) {
|
||||
Modifier.absoluteOffset(
|
||||
(pane.left - bounds.left).toDp(),
|
||||
(pane.top - bounds.top).toDp()
|
||||
)
|
||||
.size(pane.width.toDp(), pane.height.toDp())
|
||||
.consumeWindowInsets(
|
||||
WindowInsets(
|
||||
left = (pane.left - bounds.left).roundToInt(),
|
||||
top = (pane.top - bounds.top).roundToInt(),
|
||||
right = (bounds.right - pane.right).roundToInt(),
|
||||
bottom = (bounds.bottom - pane.bottom).roundToInt()
|
||||
)
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalWindowPosture provides current) {
|
||||
BoxWithConstraints(modifier) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hingeSafeBounds(bounds: Rect, posture: Posture): Rect {
|
||||
var panes = listOf(bounds)
|
||||
for (hinge in posture.hingeList.filter { it.isSeparating || it.isOccluding }) {
|
||||
panes = panes.flatMap { pane ->
|
||||
if (!pane.overlaps(hinge.bounds)) {
|
||||
listOf(pane)
|
||||
} else if (hinge.isVertical) {
|
||||
listOf(
|
||||
Rect(
|
||||
pane.left,
|
||||
pane.top,
|
||||
hinge.bounds.left.coerceIn(pane.left, pane.right),
|
||||
pane.bottom
|
||||
),
|
||||
Rect(
|
||||
hinge.bounds.right.coerceIn(pane.left, pane.right),
|
||||
pane.top,
|
||||
pane.right,
|
||||
pane.bottom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Rect(
|
||||
pane.left,
|
||||
pane.top,
|
||||
pane.right,
|
||||
hinge.bounds.top.coerceIn(pane.top, pane.bottom)
|
||||
),
|
||||
Rect(
|
||||
pane.left,
|
||||
hinge.bounds.bottom.coerceIn(pane.top, pane.bottom),
|
||||
pane.right,
|
||||
pane.bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}.filter { it.width > 0 && it.height > 0 }
|
||||
}
|
||||
val pane = if (posture.isTabletop) {
|
||||
panes.minWithOrNull(compareBy<Rect> { it.top }.thenByDescending { it.width * it.height })
|
||||
} else {
|
||||
panes.maxByOrNull { it.width * it.height }
|
||||
}
|
||||
return pane ?: Rect(bounds.topLeft, Size.Zero)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -208,7 +209,7 @@ fun HomeScreen(
|
||||
}
|
||||
if (committing) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = {
|
||||
committing = false
|
||||
message = ""
|
||||
@@ -216,7 +217,7 @@ fun HomeScreen(
|
||||
title = { Text(stringResource(R.string.home_commit_title)) },
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(state.name)
|
||||
Text(state.email)
|
||||
OutlinedTextField(
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -67,7 +66,7 @@ internal fun MutationDialog(
|
||||
if (lease.busy) authentication.cancel()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = cancel,
|
||||
title = { Text(stringResource(plan.action.label(plan.kind))) },
|
||||
@@ -112,7 +111,9 @@ internal fun MutationDialog(
|
||||
)
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(discard, role = Role.Checkbox) {
|
||||
Modifier.fillMaxWidth().heightIn(
|
||||
min = 48.dp
|
||||
).toggleable(discard, role = Role.Checkbox) {
|
||||
discard = it
|
||||
}
|
||||
) {
|
||||
@@ -127,7 +128,7 @@ internal fun MutationDialog(
|
||||
if (plan.action != PasswordMutation.Delete) {
|
||||
items(plan.destinations, key = { it.path }) { candidate ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(
|
||||
start = (
|
||||
candidate.depth.coerceAtMost(12u).toInt() *
|
||||
12
|
||||
@@ -162,7 +163,7 @@ internal fun MutationDialog(
|
||||
Text(stringResource(R.string.mutation_collision))
|
||||
target.collisionTargets.forEach { Text(it) }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
overwrite,
|
||||
role = Role.Checkbox
|
||||
) {
|
||||
|
||||
+6
-3
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
@@ -127,7 +128,7 @@ fun OnboardingScreen(
|
||||
Text(stringResource(R.string.setup_choose_key))
|
||||
state.keys.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
Modifier.heightIn(min = 48.dp).selectable(
|
||||
selected = selectedKey == item.fingerprint,
|
||||
role = Role.RadioButton,
|
||||
onClick = { key = item.fingerprint }
|
||||
@@ -253,7 +254,7 @@ fun OnboardingScreen(
|
||||
"branch" -> {
|
||||
state.branches.forEach { item ->
|
||||
Row(
|
||||
Modifier.selectable(
|
||||
Modifier.heightIn(min = 48.dp).selectable(
|
||||
selected = selectedBranch == item,
|
||||
role = Role.RadioButton,
|
||||
onClick = { branch = item }
|
||||
@@ -268,7 +269,9 @@ fun OnboardingScreen(
|
||||
it
|
||||
}
|
||||
Row(
|
||||
Modifier.toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
|
||||
Modifier.heightIn(
|
||||
min = 48.dp
|
||||
).toggleable(value = reuse, role = Role.Checkbox, onValueChange = {
|
||||
reuse =
|
||||
it
|
||||
})
|
||||
|
||||
@@ -4,8 +4,9 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -138,11 +139,16 @@ internal fun OtpCodeView(
|
||||
}
|
||||
if (confirmation) {
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = { confirmation = false },
|
||||
title = { Text(stringResource(R.string.otp_generate_hotp)) },
|
||||
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString()),
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(enabled = !busy, onClick = {
|
||||
confirmation = false
|
||||
|
||||
@@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -432,14 +433,14 @@ fun PasswordScreen(
|
||||
if (actions && state.selected != null && !state.loading) {
|
||||
val selected = requireNotNull(state.selected)
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = { actions = false },
|
||||
title = {
|
||||
Text(selected.title)
|
||||
},
|
||||
text = {
|
||||
ProtectedInputSurface()
|
||||
Column {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(selected.detail + "\n" + selected.path)
|
||||
PasswordMutation.entries.forEach { action ->
|
||||
TextButton(onClick = {
|
||||
|
||||
@@ -44,7 +44,8 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
|
||||
val view = LocalView.current.rootView
|
||||
val surface = remember(view) { protectedSurface(view) }
|
||||
val focused = remember(view) { mutableStateOf(view.hasWindowFocus()) }
|
||||
val generation by presentation.generation.collectAsState()
|
||||
// Read here so cleanup also reruns the parent surface's reveal effect.
|
||||
val generation = presentation.generation.collectAsState().value
|
||||
val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
|
||||
DisposableEffect(presentation, view) {
|
||||
val registration = presentation.register(surface::cover)
|
||||
@@ -77,7 +78,7 @@ fun ProtectedContent(presentation: ProtectedPresentation, content: @Composable (
|
||||
}
|
||||
val revealCleanContent = focused.value && lifecycle.isAtLeast(Lifecycle.State.RESUMED)
|
||||
SideEffect {
|
||||
if (revealCleanContent) {
|
||||
if (revealCleanContent && generation == presentation.generation.value) {
|
||||
surface.uncover()
|
||||
} else {
|
||||
surface.cover()
|
||||
|
||||
@@ -8,13 +8,13 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -308,7 +308,7 @@ fun SettingsScreen(
|
||||
resources.openRawResource(R.raw.license).bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
onDismissRequest = {
|
||||
licenses = false
|
||||
},
|
||||
@@ -358,7 +358,7 @@ private fun SettingsEditDialog(
|
||||
timeout = state.page?.timeout?.toString().orEmpty()
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
onDismissRequest = close,
|
||||
title = {
|
||||
@@ -455,7 +455,7 @@ internal fun KeyTransferDialog(
|
||||
}
|
||||
}
|
||||
ProtectedWindow()
|
||||
AlertDialog(
|
||||
HingeSafeAlertDialog(
|
||||
properties = DialogProperties(
|
||||
securePolicy = SecureFlagPolicy.SecureOn,
|
||||
usePlatformDefaultWidth = false
|
||||
@@ -475,7 +475,7 @@ internal fun KeyTransferDialog(
|
||||
if (!state.transfer.setup) {
|
||||
state.transfer.keys.forEach { key ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().selectable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).selectable(
|
||||
selected = fingerprint == key.fingerprint,
|
||||
enabled = !state.busy,
|
||||
role = Role.RadioButton,
|
||||
@@ -675,7 +675,7 @@ internal fun KeyTransferDialog(
|
||||
@Composable
|
||||
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(
|
||||
Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
value = checked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = change
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.rfc1437.ironstorage.features
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -78,7 +77,7 @@ fun ShellScreen(viewModel: ShellViewModel, setupForm: SetupFormViewModel) {
|
||||
listOf(route)
|
||||
}
|
||||
// Actual window bounds, rather than device type, also handle desktop and split-screen resize.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
HingeSafeLayout {
|
||||
val rail = maxWidth >= 600.dp
|
||||
val largeFont = LocalDensity.current.fontScale >= 1.6f
|
||||
Scaffold(
|
||||
|
||||
+66
@@ -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)
|
||||
}
|
||||
+13
@@ -29,5 +29,18 @@ class BaselineProfile {
|
||||
"Searchable settings must finish loading",
|
||||
device.wait(Until.findObject(By.text("Search settings")), 10000)
|
||||
)
|
||||
for ((destination, ready) in listOf(
|
||||
"Passwords" to "Password Store",
|
||||
"Search" to "Entry name or path",
|
||||
"TOTP" to "Search cached TOTP entries"
|
||||
)) {
|
||||
val navigation = device.wait(Until.findObject(By.text(destination)), 10000)
|
||||
assertNotNull("Cached navigation must be available", navigation)
|
||||
navigation.click()
|
||||
assertNotNull(
|
||||
"Cached destination must finish loading",
|
||||
device.wait(Until.findObject(By.text(ready)), 10000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -1,5 +1,6 @@
|
||||
package de.rfc1437.ironstorage.platform
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyInfo
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.filters.SdkSuppress
|
||||
@@ -35,7 +36,7 @@ class AndroidKeystoreTest {
|
||||
) = KeystoreKeySpecification(
|
||||
alias,
|
||||
auth,
|
||||
true,
|
||||
Build.VERSION.SDK_INT >= 28,
|
||||
auth == KeystoreAuthorization.StrongBiometric,
|
||||
false
|
||||
)
|
||||
@@ -51,6 +52,23 @@ class AndroidKeystoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SdkSuppress(maxSdkVersion = 27)
|
||||
fun unsupportedUnlockedDeviceFlagDoesNotCreateAKey() {
|
||||
val os = AndroidKeystoreAdapter(context)
|
||||
val alias = alias()
|
||||
try {
|
||||
failure(KeystoreFailure.UnsupportedProtection) {
|
||||
os.createKey(
|
||||
KeystoreKeySpecification(alias, KeystoreAuthorization.None, true, false, false)
|
||||
)
|
||||
}
|
||||
assertFalse(os.aliases().contains(alias))
|
||||
} finally {
|
||||
os.deleteKey(alias)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() {
|
||||
val os = AndroidKeystoreAdapter(context)
|
||||
assertFalse(os.deviceState().locked)
|
||||
|
||||
+35
@@ -2,16 +2,51 @@ package de.rfc1437.ironstorage.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidQrCameraTest {
|
||||
@Test fun backgroundStopsAnalysisAndResumeRestartsPreview() {
|
||||
val frames = AtomicInteger()
|
||||
val received = AtomicReference(CountDownLatch(1))
|
||||
ActivityScenario.launch(KeystoreTestActivity::class.java).use { activity ->
|
||||
var camera: AndroidQrCamera? = null
|
||||
activity.onActivity { host ->
|
||||
camera = AndroidQrCamera(host, { frame ->
|
||||
try {
|
||||
frames.incrementAndGet()
|
||||
received.get().countDown()
|
||||
} finally {
|
||||
frame.pixels.fill(0)
|
||||
}
|
||||
}, { throw AssertionError("CameraX failed to bind") })
|
||||
host.setContentView(requireNotNull(camera).view)
|
||||
}
|
||||
try {
|
||||
assertTrue(received.get().await(15, TimeUnit.SECONDS))
|
||||
activity.moveToState(Lifecycle.State.CREATED)
|
||||
Thread.sleep(500)
|
||||
val stopped = frames.get()
|
||||
Thread.sleep(500)
|
||||
assertEquals("Background camera analysis must stop", stopped, frames.get())
|
||||
received.set(CountDownLatch(1))
|
||||
activity.moveToState(Lifecycle.State.RESUMED)
|
||||
assertTrue("Camera preview must resume", received.get().await(15, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
activity.onActivity { camera?.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun livePreviewDeliversBoundedPlanesAndCanBeClosedAndReopened() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals(
|
||||
|
||||
+170
-89
@@ -1,108 +1,189 @@
|
||||
# Android hardening
|
||||
|
||||
The Android presentation layer uses resource strings for Kotlin-owned notices,
|
||||
action labels, counts, and durations. Rust-owned presentation text remains opaque:
|
||||
frontends do not parse or translate it. Repeated editor actions include the field
|
||||
label, and hidden editor values are checked against the actual Android accessibility
|
||||
node tree as well as Compose semantics. Compose accessibility checks run before
|
||||
interactions in every app screen test.
|
||||
Kotlin-owned notices, action labels, counts and durations use Android resources.
|
||||
Rust-owned presentation text stays opaque; frontends do not parse or translate it.
|
||||
Screen tests run Compose accessibility checks, including the finished mutation,
|
||||
camera, provider and TOTP screens. Hidden editor/viewer values are also checked
|
||||
against Android's actual accessibility node tree. Explicit buttons and semantic
|
||||
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
|
||||
zero or greater-than-4096 dimensions before allocating the decoder image; owned
|
||||
decoded luminance bytes are zeroized. License loading runs on the IO dispatcher.
|
||||
Editor keystrokes coalesce encrypted recovery checkpoints for 150 ms; save,
|
||||
reorder, and recovery wait for the latest checkpoint. This changes persistence
|
||||
scheduling, not authorization or the interactive unlock boundary.
|
||||
The shell, provider selection and all modal dialogs avoid separating/occluding
|
||||
hinges. Book posture uses the largest unobstructed pane; tabletop uses the upper
|
||||
pane. Bounds are in window coordinates, offsets remain correct in RTL, and insets
|
||||
already removed by the pane are consumed before applying system-bar/IME padding.
|
||||
Dialog titles, forms and actions can scroll at large font sizes; form state stays
|
||||
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
|
||||
|
||||
The `android_ci.py emulator <serial>` gate requires exactly one numeric measurement
|
||||
for each journey below and rejects missing, duplicate, malformed, or over-budget
|
||||
values. Raw fixture output remains local; published reports contain numeric values
|
||||
and test status only. The large-tree fixture contains 1,000 valid encrypted entries
|
||||
in 50 folders. These timings measure native/repository work, not rendered frame
|
||||
latency or a physical device.
|
||||
The gate rejects missing, duplicate, malformed or over-budget measurements. Raw
|
||||
fixture output stays local; published reports retain reviewed numbers and status.
|
||||
The tree fixture contains 10,000 encrypted files in 50 folders, replicated from
|
||||
Rust-produced public fixture ciphertext. Cached
|
||||
TOTP/provider preparation uses 100 catalogued encrypted entries while locked,
|
||||
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 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 100 editor input updates | < 500 ms | 4 ms | 6 ms |
|
||||
| Editor update p95 | < 32,000 μs | 61 μs | 102 μs |
|
||||
| TOTP discovery cache rebuild | < 15,000 ms | 1,924 ms | 1,861 ms |
|
||||
| QR image analysis | < 5,000 ms | 99 ms | 91 ms |
|
||||
| 1,000-entry tree navigation | < 3,000 ms | 11 ms | 11 ms |
|
||||
| 1,000-entry name search | < 5,000 ms | 27 ms | 26 ms |
|
||||
| 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 | 12 ms |
|
||||
| Editor update p95 | < 32,000 us | 44 us | 70 us | 231 us |
|
||||
| TOTP discovery cache rebuild | < 15,000 ms | 1,831 ms | 1,892 ms | 2,516 ms |
|
||||
| QR image analysis | < 5,000 ms | 86 ms | 93 ms | 122 ms |
|
||||
| 10,000-entry tree navigation | < 3,000 ms | 75 ms | 95 ms | 121 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
|
||||
the native core journey and bridge suite. Opt-in inspection and biometric tests
|
||||
remain assumption-skipped unless explicitly requested; these runs do not establish
|
||||
their manual coverage. Native load samples ranged from 32.7–35.2 ms on API 26 and
|
||||
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.
|
||||
All three configurations passed app/core/bridge/platform instrumentation and five
|
||||
real debug cold starts after the emulator restart. Strict 16 KB validation disabled
|
||||
the bionic/PackageManager 4 KB application compatibility path. Its five native-load
|
||||
samples were 66.4-89.7 ms and debug activity starts were 836-932 ms.
|
||||
|
||||
## 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
|
||||
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.
|
||||
## Adaptive and environment checks
|
||||
|
||||
The shell consumes IME insets, and the activity explicitly requests `adjustResize`.
|
||||
The optional `hardeningIme=true` instrumentation argument opens a real keyboard
|
||||
and checks shell content bounds against the platform IME inset. It reproduced an
|
||||
overlap before the shared-shell fix and passes afterward on API 26 and API 37 with
|
||||
strict 16 KB pages. Temporarily enable `show_ime_with_hard_keyboard` for this check
|
||||
and restore its original value afterward. Full app instrumentation also reported
|
||||
`OK (31 tests)` with `hardeningRtl=true` on the strict 16 KB image.
|
||||
On 2026-10-15 API 26 passed actual compact (411 dp), medium (640 dp), expanded
|
||||
(1,067 dp), 200% text with RTL, landscape with 200% text and RTL, and smaller-window
|
||||
checks. The real keyboard opened and shell bounds ended above its IME inset.
|
||||
Live resize from 640 to 450 dp preserved a query entered into the actual Rust
|
||||
search repository across activity recreation, followed by layout/keyboard checks.
|
||||
All size, density, font, rotation and IME settings were restored.
|
||||
|
||||
## 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
|
||||
installation and records five cold and five warm startup iterations. The benchmark
|
||||
variant uses release code and shrinking, debug signing, and shell profiling; the
|
||||
distributed release variant does not enable profiling. `ReportDrawnWhen` waits for
|
||||
the shell to leave its native loading state. Compilation is preserved rather than
|
||||
resetting application data.
|
||||
The available ARM images do not advertise freeform/desktop support. An explicit
|
||||
freeform launch on the foldable was accepted but fell back to fullscreen; it is
|
||||
not counted as desktop validation. Actual split-screen, live resize and expanded
|
||||
window geometry are covered. Desktop-specific system decorations remain an image
|
||||
availability limit; no x86_64 or physical-device coverage is implied.
|
||||
|
||||
The startup gate requires five complete numeric initial/full-display samples for
|
||||
each mode, with cold startup below 5,000 ms and warm startup below 3,000 ms.
|
||||
Before installing the generated profile, release-like median full-display times
|
||||
were 268.6 ms cold / 131.2 ms warm on API 26 and 1,203.5 ms cold / 427.0 ms warm on
|
||||
API 37. These emulator observations preserve existing compilation state and are
|
||||
not a claim of profile speedup. Raw traces and device metadata stay local; CI
|
||||
artifacts retain only API/ABI and the reviewed numeric samples.
|
||||
The old API 26 Pixel Launcher crashed during a tablet-size transition. The blocked
|
||||
attempt was stopped and its display overrides restored. Remaining cases passed
|
||||
with Settings in front during transitions, avoiding that launcher defect. An
|
||||
actual split-screen smoke check also showed IronStorage alongside Settings;
|
||||
setup controls remained reachable by scrolling and fullscreen was restored.
|
||||
|
||||
`android_ci.py profiles <serial>` generates a profile from startup and the existing
|
||||
Preferences journey. Generation requires API 34 or newer so compilation reset
|
||||
does not fall back to reinstalling the app on older non-rooted images. Inspect the
|
||||
generated local artifacts before promoting them into the app source set. Generation
|
||||
uses a non-obfuscated `profile` variant, while startup measurements use the shrunk
|
||||
`benchmark` variant. Both use release source code and debug signing.
|
||||
The current phone passed the full app suite with 200% system text, RTL, high text
|
||||
contrast, grayscale and all three animation scales at zero. A focused follow-up
|
||||
also covered the final scrolling dialogs and tabletop generator. The native
|
||||
cached-catalog/provider fixture passed under forced deep doze (still IDLE after
|
||||
the run), battery saver, disabled Wi-Fi/mobile data, and Android's forced-low
|
||||
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
|
||||
emulator from the startup/Preferences journey on 2026-10-11, with 22,331 rules and
|
||||
SHA-256 `687afdc199dacf0736dc1c577767dea9b81ddc987b5b61b02b5afed5e501b503`.
|
||||
Regenerate it with the `profiles` command after changing those journeys; use the
|
||||
untimestamped `BaselineProfile_startupAndPreferences-baseline-prof.txt` output,
|
||||
check that application class names are unobfuscated, and rebuild the release APK
|
||||
to verify its compiled `assets/dexopt/baseline.prof` remains within ART's size limit.
|
||||
Computer use authenticated and decrypted the retained public provider fixture on
|
||||
strict-16-KB API 37 using its source-defined passphrase. Its six Git commits and
|
||||
clean worktree survived install-over updates. The password stayed Hidden with
|
||||
explicit Reveal/Copy/Edit/Lock controls. TalkBack was enabled through Android
|
||||
Studio and verified as a bound service with touch exploration active. Computer
|
||||
use focused the native Hidden value and activated/cancelled the native editor
|
||||
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),
|
||||
TOTP list/detail (#107), mutation workflows (#108), Credential Manager/Autofill
|
||||
(#109), and TOTP discovery UI (#110) are still open. Their final screen accessibility,
|
||||
camera interruption, cached-open/refresh presentation, and provider response
|
||||
latency cannot be certified by tests of the existing screens. Keep #105 open until
|
||||
those implementations and the remaining environment/posture checks are complete.
|
||||
ARMv7 is package-validated; the installed runnable emulator images are ARM64.
|
||||
The other retained phone vault rejected its historical public test passphrase;
|
||||
that journey is not a successful manual unlock. Rejection and subsequent Search
|
||||
navigation did verify the foreground-cleanup fix. No credential or vault reset
|
||||
was performed. The separately source-defined provider fixture above supplies the
|
||||
successful native decryption journey.
|
||||
|
||||
On 2026-10-11 the user explicitly requested committing the independent implementation
|
||||
now and delaying final coverage. Issues #106–#110 received `critical`; resume those
|
||||
eligible Android enhancements in numeric order, then revisit #105. Remaining
|
||||
checks include TalkBack/Switch Access and contrast/motion modes across the finished
|
||||
screens, real hinge/posture and live-resize coverage, and low-memory, doze, battery,
|
||||
network, camera-interruption, and storage-pressure scenarios. Do not close #105
|
||||
based solely on the current instrumentation and window-geometry results.
|
||||
## Release startup and profiles
|
||||
|
||||
`performance` installs the shrunk benchmark variant over the retained app. It uses
|
||||
release source, debug signing and shell profiling; distributed release builds do
|
||||
not enable profiling. `ReportDrawnWhen` waits for native shell loading to finish.
|
||||
CompilationMode.Ignore preserves compilation and application data. Five complete
|
||||
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.
|
||||
Reference in new issue
Block a user