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