Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions .idea/deploymentTargetSelector.xml

This file was deleted.

9 changes: 0 additions & 9 deletions .idea/misc.xml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.composea11yscanner.sample

import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ActivityScenario
import com.composea11yscanner.core.A11yScanEngine
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.core.model.ScannerState
import com.composea11yscanner.rules.FocusOrderRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/** Exercises the production extraction path with Send Payment below a scrolling viewport. */
class FormFocusOrderTest {
@Test
fun brokenFormReportsAmountWithSubmitOffscreen() = checkForm(fixed = false)

@Test
fun fixedFormHasNoFocusJumpWithSubmitOffscreen() = checkForm(fixed = true)

private fun checkForm(fixed: Boolean) {
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
lateinit var activity: SampleActivity
scenario.onActivity {
activity = it
it.setContent {
MaterialTheme {
Box(Modifier.height(340.dp).verticalScroll(rememberScrollState())
.semantics { testTag = SampleViewportTag }) {
Box(Modifier.semantics { testTag = BrokenSampleContentTag }) {
if (fixed) FixedFormScreen() else BrokenFormScreen()
}
}
}
}
}
runBlocking {
val snapshot = withTimeout(10_000) {
var nodes = SampleScanNodes()
while (nodes.visibleNodes.none { it.contentDescription?.contains("Amount") == true }) {
delay(100)
nodes = withContext(Dispatchers.Main) { activity.extractBrokenSampleNodes() }
}
nodes
}
val submit = snapshot.focusOrderNodes.first { it.contentDescription == "Send Payment" }
assertFalse("Submit must be offscreen to reproduce the regression", submit.isVisibleToUser)
assertFalse("Offscreen submit must retain its layout bounds", submit.bounds.isEmpty())
assertTrue(snapshot.visibleNodes.none { it.nodeId == submit.nodeId })
val engine = A11yScanEngine(
listOf(FocusOrderRule(activity.resources.displayMetrics.density)),
ScannerConfig(enabledRules = setOf("focus-order")),
)
val state = engine.scan(snapshot.visibleNodes,
mapOf("focus-order" to snapshot.focusOrderNodes)).last() as ScannerState.Complete
val issues = state.result.issues
if (fixed) {
assertTrue("Fixed form should pass: $issues", issues.isEmpty())
} else {
assertTrue("Expected Amount focus jump: $issues", issues.any {
it.affectedNode.contentDescription?.contains("Amount") == true
})
}
assertTrue(issues.all { it.affectedNode.isVisibleToUser })
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.composea11yscanner.sample

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Rule
import org.junit.Test

class SampleScannerControlsTest {
@get:Rule
val composeRule = createAndroidComposeRule<SampleActivity>()

@Test
fun inspectionControlAppearsOnLaunchAndAfterChangingSamples() {
awaitInspectionControl()
composeRule.onNodeWithContentDescription("Clear scan results").assertDoesNotExist()
composeRule.onNodeWithContentDescription("Scan selected sample").assertDoesNotExist()
composeRule.onNodeWithContentDescription("Interact with app").performClick()
composeRule.onNodeWithContentDescription("Resume issue inspection")
.assertIsDisplayed().performClick()
composeRule.onNodeWithContentDescription("Interact with app").performClick()
composeRule.onNodeWithText("Feed").performClick()
awaitInspectionControl()
composeRule.onNodeWithContentDescription("Interact with app").performClick()
composeRule.onNodeWithText("Fixed").performClick()
awaitInspectionControl()
}

private fun awaitInspectionControl() {
composeRule.waitUntil(timeoutMillis = 15_000) {
composeRule.onAllNodesWithContentDescription("Interact with app")
.fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithContentDescription("Interact with app").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,15 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
Expand All @@ -45,6 +42,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -92,12 +90,15 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) {
derivedStateOf { scanScrollY - scrollState.value }
}
val scannerController = remember(activity) {
var scanNodes = SampleScanNodes()
A11yScannerController(
nodeProvider = {
scanScrollY = scrollState.value
activity?.extractBrokenSampleNodes().orEmpty()
scanNodes = activity?.extractBrokenSampleNodes() ?: SampleScanNodes()
scanNodes.visibleNodes
},
screenDensity = activity?.resources?.displayMetrics?.density ?: 1f,
ruleNodeOverridesProvider = { mapOf("focus-order" to scanNodes.focusOrderNodes) },
)
}
val scannerConfig = remember(selectedScreen, viewingFixed) {
Expand All @@ -115,6 +116,10 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) {
LaunchedEffect(selectedScreen, viewingFixed) {
scanScrollY = scrollState.value
scannerController.clearState()
// Wait for the selected sample to be laid out before extracting bounds.
withFrameNanos { }
withFrameNanos { }
startSampleScan()
}

scanOnShake(onScanRequested = { startSampleScan() })
Expand All @@ -125,14 +130,12 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) {
modifier = modifier.fillMaxSize(),
issueOffsetY = issueOffsetY,
summaryBarTopOffset = 64.dp,
inspectionToggleBottomOffset = 80.dp,
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
SampleTopBar(
onClear = { scannerController.clearState() },
onScan = { startSampleScan() },
)
SampleTopBar()
},
bottomBar = {
SampleBottomBar(
Expand Down Expand Up @@ -164,10 +167,7 @@ fun BrokenAccessibilitySampleApp(modifier: Modifier = Modifier) {

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SampleTopBar(
onClear: () -> Unit,
onScan: () -> Unit,
) {
private fun SampleTopBar() {
CenterAlignedTopAppBar(
title = {
Text(
Expand All @@ -176,20 +176,6 @@ private fun SampleTopBar(
fontWeight = FontWeight.SemiBold,
)
},
actions = {
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = "Clear scan results",
)
}
IconButton(onClick = onScan) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Scan selected sample",
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.composea11yscanner.sample

import android.os.Build
import android.view.View
import android.view.ViewGroup
import androidx.activity.ComponentActivity
Expand All @@ -12,31 +13,62 @@ import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.ui.A11yNodeExtractor
import com.composea11yscanner.ui.RenderedTextContrastAnalyzer
import com.composea11yscanner.ui.captureRenderedView
import kotlin.math.roundToInt

internal const val BrokenSampleContentTag = "broken-sample-content"
internal const val SampleViewportTag = "sample-viewport"

internal fun ComponentActivity.extractBrokenSampleNodes(): List<A11yNode> =
internal data class SampleScanNodes(
val visibleNodes: List<A11yNode> = emptyList(),
val focusOrderNodes: List<A11yNode> = emptyList(),
)

internal suspend fun ComponentActivity.extractBrokenSampleNodes(): SampleScanNodes =
runCatching {
val hostView = (window.decorView as? ViewGroup)
?.findFirstAbstractComposeView()
?: return emptyList()
val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList()
?: return SampleScanNodes()
val semanticsOwner = hostView.findSemanticsOwner() ?: return SampleScanNodes()
val sampleRoot = semanticsOwner.unmergedRootSemanticsNode
.findNodeByTestTag(BrokenSampleContentTag)
?: return emptyList()
?: return SampleScanNodes()
val viewport = semanticsOwner.unmergedRootSemanticsNode
.findNodeByTestTag(SampleViewportTag)
?.boundsInRoot
?.let { Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) }
?: sampleRoot.boundsInRoot.let {
Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt())
}
RenderedTextContrastAnalyzer(hostView)
.analyze(A11yNodeExtractor().extract(sampleRoot))
.filterVisibleIn(viewport)
}.getOrDefault(emptyList())
val allNodes = A11yNodeExtractor().extract(sampleRoot)
// PixelCopy window capture requires API 26; keep semantic checks on API 24–25.
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
captureRenderedView(window, hostView)
} else {
null
}
try {
val analyzedNodes = if (bitmap != null) {
RenderedTextContrastAnalyzer(hostView).analyze(allNodes, bitmap)
} else {
allNodes
}
val visibleNodes = analyzedNodes.filterVisibleIn(viewport)
val visibleIds = visibleNodes.map { it.nodeId }.toSet()
SampleScanNodes(
visibleNodes = visibleNodes,
// Preserve offscreen predecessors only for traversal analysis.
focusOrderNodes = allNodes.map { node ->
node.copy(
bounds = node.unclippedBounds ?: node.bounds,
isVisibleToUser = node.nodeId in visibleIds,
)
},
)
} finally {
bitmap?.recycle()
}
}.getOrDefault(SampleScanNodes())

private fun List<A11yNode>.filterVisibleIn(viewport: Rect): List<A11yNode> =
filter { node ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ class A11yScanEngine(
* @param nodes Nodes extracted from the UI semantics tree.
* @return Flow of [ScannerState] values for progress, success, or failure.
*/
fun scan(nodes: List<A11yNode>): Flow<ScannerState> = flow {
fun scan(
nodes: List<A11yNode>,
ruleNodeOverrides: Map<String, List<A11yNode>> = emptyMap(),
): Flow<ScannerState> = flow {
// Fast path: nothing to evaluate.
if (enabledRules.isEmpty() || nodes.isEmpty()) {
emit(ScannerState.Complete(buildResult(nodes.size, emptyList(), emptySet())))
Expand All @@ -55,7 +58,9 @@ class A11yScanEngine(

try {
enabledRules.forEachIndexed { index, rule ->
val issues = rule.evaluateAll(nodes)
val ruleNodes = ruleNodeOverrides[rule.ruleId] ?: nodes
// Context nodes participate in traversal but never produce offscreen issues.
val issues = rule.evaluateAll(ruleNodes).filter { it.affectedNode.isVisibleToUser }
allIssues += issues
if (issues.isNotEmpty()) failedRuleIds += rule.ruleId
// Progress advances to 1f after the last rule.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ package com.composea11yscanner.core.model
* @property composableName Best-effort composable or role name used in reports.
* @property bounds Pixel bounds relative to the scanned root.
* @property contentDescription Accessible label exposed by the node, if any.
* @property textLabel Visible semantic text TalkBack can append to [contentDescription]. Keeping
* this separate lets rules reason about the complete spoken label instead of treating a shared
* prefix, such as "Book cover image", as the whole accessible name.
* @property hasExplicitContentDescription True when [contentDescription] came from Compose's
* ContentDescription semantics rather than falling back to [textLabel].
* @property isTouchTarget True when the node exposes a click action.
* @property effectiveTouchBounds Effective pointer target bounds in root pixels for clickable nodes.
* @property textColor Foreground text color when it can be extracted.
Expand Down Expand Up @@ -36,4 +41,12 @@ data class A11yNode(
val parentNodeId: String? = null,
val isEnabled: Boolean = true,
val isCollectionContainer: Boolean = false,
val textLabel: String? = null,
val hasExplicitContentDescription: Boolean = false,
/** Whether this node establishes a separate accessibility traversal scope. */
val isTraversalGroup: Boolean = false,
/** Full layout bounds, including content clipped by a scrolling viewport. */
val unclippedBounds: Rect? = null,
/** False for context-only nodes that must not receive an issue highlight. */
val isVisibleToUser: Boolean = true,
)
Loading
Loading