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

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

No files matched your search

@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -14,11 +15,16 @@ import de.rfc1437.ironstorage.core.CredentialCandidate
import de.rfc1437.ironstorage.core.ProviderRequest
import de.rfc1437.ironstorage.features.CredentialSelectionScreen
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CredentialSelectionScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private var selected by mutableStateOf<String?>(null)
private var destination by mutableStateOf("")
private var consent by mutableStateOf(false)
@@ -2,8 +2,12 @@ package de.rfc1437.ironstorage
import android.os.Bundle
import android.os.Parcel
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
@@ -20,10 +24,13 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationRepository
@@ -35,6 +42,7 @@ import de.rfc1437.ironstorage.core.EditorState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.EditorDiscardDialog
import de.rfc1437.ironstorage.features.EntryEditorScreen
import de.rfc1437.ironstorage.features.HingeSafeLayout
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -50,6 +58,55 @@ class EntryEditorScreenTest {
compose.enableAccessibilityChecks()
}
@Test fun tabletopGeneratorKeepsEveryControlReachableAtLargeFont() {
val repository = Fixture()
val authentication = object : AuthenticationRepository by compose.activity.authentication {
override val state = MutableStateFlow(AuthenticationState(unlocked = true))
}
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints {
val density = LocalDensity.current
val width = with(density) { maxWidth.toPx() }
val height = with(density) { maxHeight.toPx() }
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
MaterialTheme {
HingeSafeLayout(
Posture(
true,
listOf(
HingeInfo(
Rect(
0f,
height * .5f,
width,
height * .5f + 12f
),
false,
false,
true,
true
)
)
)
) { EntryEditorScreen(repository, authentication) }
}
}
}
}
}
compose.onNodeWithTag("entry-editor")
.performScrollToNode(hasText("Generate password"))
compose.onNodeWithText("Generate password").performClick()
compose.onNodeWithText("Password length").performScrollTo().performTextReplacement("24")
compose.onNodeWithTag("hinge-dialog-content").performTouchInput { swipeUp() }
compose.onNodeWithText("Use letters and numbers only").performScrollTo()
compose.onNodeWithText("Use letters and numbers only").assertIsDisplayed()
compose.onNodeWithText("Generate replacement").performScrollTo().performClick()
assertEquals(1, repository.generations)
assertEquals(24u, repository.length)
}
@Test fun structuredInputsGenerationAndAccessibleReorderForwardTypedIds() {
val repository = Fixture()
val lease = MutableStateFlow(AuthenticationState(unlocked = true))
@@ -0,0 +1,98 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.HingeInfo
import androidx.compose.material3.adaptive.Posture
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import de.rfc1437.ironstorage.core.HomeActionKind
import de.rfc1437.ironstorage.features.HingeSafeLayout
import de.rfc1437.ironstorage.features.HomeScreen
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class HingeWindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun livePostureChangesKeepContentAndProtectedDialogOutsideTheHinge() {
compose.enableAccessibilityChecks()
val repository = HomeScreenTest.Fixture()
var mode by mutableStateOf(0)
var hinge = Rect.Zero
var density = 1f
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
BoxWithConstraints(Modifier.fillMaxSize()) {
density = LocalDensity.current.density
val width = maxWidth.value * density
val height = maxHeight.value * density
hinge = if (mode == 1) {
Rect(width * .35f, 0f, width * .35f + 12f, height)
} else {
Rect(0f, height * .5f, width, height * .5f + 12f)
}
val posture = Posture(
isTabletop = mode == 2,
hingeList = if (mode == 0) {
emptyList()
} else {
listOf(
HingeInfo(hinge, false, mode == 1, true, true)
)
}
)
MaterialTheme {
HingeSafeLayout(posture) { HomeScreen(repository) }
}
}
}
}
compose.onAllNodesWithText("Commit")[0].performScrollTo().performClick()
compose.onNodeWithText("Commit message").performTextInput("Public posture fixture")
for (posture in listOf(1, 2, 0)) {
compose.runOnIdle { mode = posture }
if (posture != 0) {
compose.onNodeWithText("Create commit").performScrollTo()
}
val action = compose.onNodeWithText("Create commit")
action.assertIsDisplayed()
if (posture != 0) {
val bounds = action.getUnclippedBoundsInRoot()
val pixels = Rect(
bounds.left.value * density,
bounds.top.value * density,
bounds.right.value * density,
bounds.bottom.value * density
)
assertTrue("Dialog action must avoid the hinge", !pixels.overlaps(hinge))
if (posture == 2) assertTrue(pixels.bottom <= hinge.top)
compose.onNodeWithText("Public posture fixture").performScrollTo()
}
compose.onNodeWithText("Public posture fixture")
.assertTextContains("Public posture fixture")
}
compose.onNodeWithText("Create commit").performClick()
assertEquals(
HomeActionKind.Commit to "Public posture fixture",
repository.requests.single()
)
}
}
@@ -9,7 +9,6 @@ import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
@@ -93,12 +92,12 @@ class HomeScreenTest {
@Test fun swipeRefreshRequestsTheRustRefreshWorkflow() {
val repository = Fixture()
show(repository)
compose.onRoot().performTouchInput { swipeDown() }
compose.onNode(hasScrollAction()).performTouchInput { swipeDown() }
compose.waitUntil(5000) { repository.refreshes == 1 }
assertTrue(repository.requests.isEmpty())
compose.onNodeWithText("Cancel").performClick()
}
private class Fixture(changes: Int = 1) : HomeRepository {
internal class Fixture(changes: Int = 1) : HomeRepository {
private val actions = HomeActionKind.entries.map {
HomeAction(
it,
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
@@ -23,6 +24,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.PasswordDestination
@@ -38,12 +40,17 @@ import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class MutationWorkflowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
@@ -55,7 +62,10 @@ class MutationWorkflowTest {
@Test fun fullSwipeOnlySelectsDeleteAndCancelNeverMutates() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Source").performTouchInput { swipeLeft() }
val rtl = InstrumentationRegistry.getArguments().getString("hardeningRtl") == "true"
compose.onNodeWithText("Source").performTouchInput {
if (rtl) swipeRight() else swipeLeft()
}
compose.waitUntil(5000) { fixture.state.value.mutation != null }
assertEquals(PasswordMutation.Delete, fixture.state.value.mutation!!.action)
assertEquals(0, fixture.committed)
@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
@@ -49,6 +50,43 @@ class PresentationSecurityTest {
compose.enableAccessibilityChecks()
}
@Test fun foregroundCleanupUnmasksOnlyAfterTheCleanGenerationIsComposed() {
val presentation = de.rfc1437.ironstorage.core.ProtectedPresentation()
val dirty = mutableStateOf(true)
val registration = presentation.register { dirty.value = false }
try {
compose.runOnIdle {
compose.activity.setProtectedTestContent(presentation) {
MaterialTheme {
Text(
if (dirty.value) "Disposable protected fixture" else "Clean foreground"
)
}
}
}
compose.onNodeWithText("Disposable protected fixture").assertIsDisplayed()
compose.runOnIdle {
presentation.clear()
assertEquals(
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
compose.activity.window.decorView.importantForAccessibility
)
assertFalse(dirty.value)
}
compose.onNodeWithText("Clean foreground").assertIsDisplayed()
compose.onNodeWithText("Disposable protected fixture").assertDoesNotExist()
compose.runOnIdle {
assertFalse(
"The rebuilt foreground surface must be visible to accessibility",
compose.activity.window.decorView.importantForAccessibility ==
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
)
}
} finally {
registration.close()
}
}
@Test fun inspectTaskSwitchingWithDisposableProtectedInput() {
assumeTrue(InstrumentationRegistry.getArguments().getString("securityInspect") == "true")
val done = CountDownLatch(1)
@@ -5,6 +5,7 @@ import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isHeading
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
@@ -28,9 +29,7 @@ class ShellScreenTest {
compose.onNodeWithText("Fixture: ${state.name}").performScrollTo().performClick()
ShellTab.entries.forEach { tab ->
val page = ShellFixtures.all.getValue(state).first { it.tab == tab }
compose.onNode(
hasText(page.title) and androidx.compose.ui.test.isSelectable()
).performClick()
compose.onNodeWithTag("shell-tab-${tab.name}").performClick()
compose.waitUntil(timeoutMillis = 10_000) {
compose.onAllNodes(hasText(page.stateTitle) and isHeading())
.fetchSemanticsNodes().size == 1
@@ -1,5 +1,9 @@
package de.rfc1437.ironstorage
import android.app.KeyguardManager
import android.os.Build
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.Key
@@ -20,6 +24,7 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.pressKey
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -30,10 +35,12 @@ import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.AuthenticationState
import de.rfc1437.ironstorage.core.ProtectedPresentation
import de.rfc1437.ironstorage.features.ApplicationTheme
import de.rfc1437.ironstorage.features.SearchScreen
import de.rfc1437.ironstorage.features.SetupFormViewModel
import de.rfc1437.ironstorage.features.ShellScreen
import de.rfc1437.ironstorage.features.ShellViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -43,8 +50,32 @@ class Tier2WindowTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun settingsAndKeyboardNavigationFitTheCurrentWindow() {
compose.runOnIdle {
if (Build.VERSION.SDK_INT >= 27) compose.activity.setTurnScreenOn(true)
val keyguard = compose.activity.getSystemService(KeyguardManager::class.java)
if (!keyguard.isDeviceSecure) keyguard.requestDismissKeyguard(compose.activity, null)
}
compose.enableAccessibilityChecks()
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
if (InstrumentationRegistry.getArguments().getString("hardeningResize") == "true") {
val initialWidth = compose.activity.window.decorView.width
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme { SearchScreen(original.search, {}) }
}
}
compose.onNodeWithText("Entry name or path").performTextReplacement("appearance")
InstrumentationRegistry.getInstrumentation().sendStatus(
0,
Bundle().apply { putBoolean("ironstorage_resize_ready", true) }
)
compose.waitUntil(30000) { compose.activity.window.decorView.width != initialWidth }
compose.runOnIdle {
val current = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
assertEquals("appearance", current.search.state.value.query)
current.search.clear()
}
}
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
val authentication = object : AuthenticationRepository by original.authentication {
override val state = MutableStateFlow(AuthenticationState())
@@ -72,7 +103,8 @@ class Tier2WindowTest {
val width =
compose.activity.window.decorView.width /
compose.activity.resources.displayMetrics.density
val navigation = if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
val navigation = InstrumentationRegistry.getArguments().getString("expectedNavigation")
?: if (width >= 600) "shell-navigation-rail" else "shell-bottom-navigation"
compose.onNodeWithTag(navigation).assertIsDisplayed()
compose.onNodeWithTag("settings-search").assertIsDisplayed()
if (InstrumentationRegistry.getArguments().getString("hardeningIme") == "true") {
@@ -12,6 +12,7 @@ import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
@@ -36,6 +37,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -43,6 +45,10 @@ import org.junit.Test
class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Before fun enableChecks() {
compose.enableAccessibilityChecks()
}
private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
File diff suppressed because it is too large. Load diff