diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
deleted file mode 100644
index 5b63c6a..0000000
--- a/.idea/deploymentTargetSelector.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index b2c751a..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/FormFocusOrderTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/FormFocusOrderTest.kt
new file mode 100644
index 0000000..0159f9a
--- /dev/null
+++ b/sample/src/androidTest/java/com/composea11yscanner/sample/FormFocusOrderTest.kt
@@ -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 })
+ }
+ }
+ }
+}
diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/SampleScannerControlsTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/SampleScannerControlsTest.kt
new file mode 100644
index 0000000..c45923b
--- /dev/null
+++ b/sample/src/androidTest/java/com/composea11yscanner/sample/SampleScannerControlsTest.kt
@@ -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()
+
+ @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()
+ }
+}
diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt
index 7d82f81..85930a9 100644
--- a/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt
+++ b/sample/src/main/java/com/composea11yscanner/sample/SampleActivity.kt
@@ -19,10 +19,8 @@ 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
@@ -30,7 +28,6 @@ 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
@@ -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
@@ -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) {
@@ -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() })
@@ -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(
@@ -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(
@@ -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,
),
diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt
index 757991c..93c62c6 100644
--- a/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt
+++ b/sample/src/main/java/com/composea11yscanner/sample/SampleSemanticsExtractor.kt
@@ -1,5 +1,6 @@
package com.composea11yscanner.sample
+import android.os.Build
import android.view.View
import android.view.ViewGroup
import androidx.activity.ComponentActivity
@@ -12,20 +13,26 @@ 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 =
+internal data class SampleScanNodes(
+ val visibleNodes: List = emptyList(),
+ val focusOrderNodes: List = 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
@@ -33,10 +40,35 @@ internal fun ComponentActivity.extractBrokenSampleNodes(): List =
?: 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.filterVisibleIn(viewport: Rect): List =
filter { node ->
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
index 25501cb..160abb0 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
@@ -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): Flow = flow {
+ fun scan(
+ nodes: List,
+ ruleNodeOverrides: Map> = emptyMap(),
+ ): Flow = flow {
// Fast path: nothing to evaluate.
if (enabledRules.isEmpty() || nodes.isEmpty()) {
emit(ScannerState.Complete(buildResult(nodes.size, emptyList(), emptySet())))
@@ -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.
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
index 80b5622..36b24cb 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
@@ -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.
@@ -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,
)
diff --git a/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt
index db95426..0c4ef70 100644
--- a/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt
+++ b/scanner-core/src/test/java/com/composea11yscanner/core/A11yScanEngineTest.kt
@@ -294,6 +294,29 @@ class A11yScanEngineTest {
verify(exactly = 1) { rule.evaluateAll(nodes) }
}
+ @Test
+ fun `rule override is isolated to its matching rule`() = runTest {
+ val mergedNodes = listOf(stubNode("merged"))
+ val contrastNodes = listOf(stubNode("leaf-text"))
+ val semanticRule = mockRule("missing-content-description")
+ val contrastRule = mockRule("text-contrast")
+ val engine = A11yScanEngine(
+ rules = listOf(semanticRule, contrastRule),
+ config = configOf("missing-content-description", "text-contrast"),
+ )
+
+ engine.scan(
+ nodes = mergedNodes,
+ ruleNodeOverrides = mapOf("text-contrast" to contrastNodes),
+ ).test {
+ repeat(4) { awaitItem() }
+ awaitComplete()
+ }
+
+ verify(exactly = 1) { semanticRule.evaluateAll(mergedNodes) }
+ verify(exactly = 1) { contrastRule.evaluateAll(contrastNodes) }
+ }
+
@Test
fun `scanId is non-blank in every Complete result`() = runTest {
val engine = A11yScanEngine(rules = emptyList(), config = configOf())
@@ -304,6 +327,27 @@ class A11yScanEngineTest {
}
}
+ @Test
+ fun `context-only issues are excluded from results and failed rule counts`() = runTest {
+ val visible = stubNode("visible")
+ val hidden = stubNode("offscreen").copy(isVisibleToUser = false)
+ val hiddenIssue = stubIssue("focus-order").copy(affectedNode = hidden)
+ val rule = mockRule("focus-order", listOf(hiddenIssue))
+ val engine = A11yScanEngine(listOf(rule), configOf("focus-order"))
+ val traversalNodes = listOf(hidden, visible)
+ engine.scan(listOf(visible), mapOf("focus-order" to traversalNodes)).test {
+ awaitItem()
+ awaitItem()
+ val result = (awaitItem() as ScannerState.Complete).result
+ assertTrue(result.issues.isEmpty())
+ assertEquals(0, result.failedRules)
+ assertEquals(1, result.passedRules)
+ assertEquals(1, result.totalNodes)
+ awaitComplete()
+ }
+ verify { rule.evaluateAll(traversalNodes) }
+ }
+
// ── error handling ────────────────────────────────────────────────────────
@Test
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
index e14dab2..21e8bb5 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
@@ -36,17 +36,31 @@ class DuplicateContentDescriptionRule : BaseScanRule() {
}
.toList()
val candidateIds = candidates.mapTo(mutableSetOf(), A11yNode::nodeId)
+ val withinTargetDuplicates = candidates.mapNotNull { node ->
+ node.withinTargetDuplicateLabel()?.let { duplicatedLabel ->
+ issue(
+ node = node,
+ message = "This element announces '$duplicatedLabel' more than once because " +
+ "its content description duplicates its visible text.",
+ howToFix = "Remove the redundant content description. When visible text already " +
+ "labels a control, set its decorative icon's contentDescription to null.",
+ )
+ }
+ }
+ val withinTargetDuplicateIds = withinTargetDuplicates
+ .mapTo(mutableSetOf()) { it.affectedNode.nodeId }
- return candidates
+ val crossTargetDuplicates = candidates
.asSequence()
+ .filterNot { it.nodeId in withinTargetDuplicateIds }
.filterNot { node -> node.isCopyOfLabeledAncestor(candidateIds, nodesById) }
.groupBy { node ->
val scopeId = node.nearestCollectionAncestorId(nodesById) ?: node.parentNodeId
- scopeId to node.normalizedDescription()
+ scopeId to node.normalizedSpokenLabel()
}
.filter { (_, group) -> group.size > 1 }
.flatMap { (_, group) ->
- val text = group.first().contentDescription!!.trim()
+ val text = group.first().spokenLabel()
group.map { node ->
issue(
node = node,
@@ -58,6 +72,8 @@ class DuplicateContentDescriptionRule : BaseScanRule() {
}
}
.toList()
+
+ return withinTargetDuplicates + crossTargetDuplicates
}
private fun A11yNode.nearestCollectionAncestorId(
@@ -95,6 +111,27 @@ class DuplicateContentDescriptionRule : BaseScanRule() {
private fun A11yNode.normalizedDescription(): String =
contentDescription.orEmpty().trim().lowercase()
+ private fun A11yNode.withinTargetDuplicateLabel(): String? {
+ if (!hasExplicitContentDescription) return null
+ val description = contentDescription?.trim()?.takeIf(String::isNotBlank) ?: return null
+ val text = textLabel?.trim()?.takeIf(String::isNotBlank) ?: return null
+ return description.takeIf { it.equals(text, ignoreCase = true) }
+ }
+
+ /**
+ * Approximates the label TalkBack speaks for a merged Compose node. Compose can expose both
+ * ContentDescription and Text, and TalkBack announces both. Comparing only the former makes
+ * distinct labels such as "Book cover image, Moby Dick" and "Book cover image, Frankenstein"
+ * look identical.
+ */
+ private fun A11yNode.spokenLabel(): String =
+ listOfNotNull(contentDescription?.trim(), textLabel?.trim())
+ .filter(String::isNotBlank)
+ .distinctBy { it.lowercase() }
+ .joinToString(separator = ", ")
+
+ private fun A11yNode.normalizedSpokenLabel(): String = spokenLabel().lowercase()
+
private fun com.composea11yscanner.core.model.Rect.contains(
other: com.composea11yscanner.core.model.Rect,
): Boolean =
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
index ae69526..8ea235d 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
@@ -85,10 +85,20 @@ class FocusOrderRule(
return false
}
- return when {
- first.parentNodeId == null && second.parentNodeId == null -> true
- else -> first.parentNodeId == second.parentNodeId
+ return first.traversalScope(nodesById) == second.traversalScope(nodesById)
+ }
+
+ /** Non-focusable semantics wrappers do not establish a traversal boundary. */
+ private fun A11yNode.traversalScope(nodesById: Map): String? {
+ var parentId = parentNodeId
+ val visited = mutableSetOf()
+ while (parentId != null && visited.add(parentId)) {
+ // Preserve unknown boundaries when callers supply only part of a tree.
+ val parent = nodesById[parentId] ?: return parentId
+ if (parent.isTraversalGroup || parent.isFocusable) return parentId
+ parentId = parent.parentNodeId
}
+ return parentId
}
private fun A11yNode.hasCollectionAncestor(nodesById: Map): Boolean {
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
index 48908df..9bbf9b2 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt
@@ -27,6 +27,69 @@ class DuplicateContentDescriptionRuleTest {
assertTrue(rule.evaluateAll(nodes).isEmpty())
}
+ @Test
+ fun `shared description prefix with unique spoken text is not a duplicate`() {
+ val nodes = listOf(
+ createNode(
+ bounds = Rect(0, 0, 100, 100),
+ contentDescription = "Book cover image",
+ textLabel = "Moby Dick, Herman Melville, English",
+ isTouchTarget = true,
+ ),
+ createNode(
+ bounds = Rect(0, 100, 100, 200),
+ contentDescription = "Book cover image",
+ textLabel = "Pride and Prejudice, Jane Austen, English",
+ isTouchTarget = true,
+ ),
+ )
+
+ assertTrue(rule.evaluateAll(nodes).isEmpty())
+ }
+
+ @Test
+ fun `explicit description matching visible text produces one within-target issue`() {
+ val homeItem = createNode(
+ composableName = "ClickableText",
+ contentDescription = "Home",
+ textLabel = "Home",
+ hasExplicitContentDescription = true,
+ isTouchTarget = true,
+ )
+
+ val issues = rule.evaluateAll(listOf(homeItem))
+
+ assertEquals(1, issues.size)
+ assertTrue(issues.single().message.contains("announces 'Home' more than once"))
+ assertTrue(issues.single().howToFix.contains("contentDescription to null"))
+ }
+
+ @Test
+ fun `text fallback matching visible text is not a within-target duplicate`() {
+ val textOnlyItem = createNode(
+ composableName = "ClickableText",
+ contentDescription = "Home",
+ textLabel = "Home",
+ hasExplicitContentDescription = false,
+ isTouchTarget = true,
+ )
+
+ assertTrue(rule.evaluateAll(listOf(textOnlyItem)).isEmpty())
+ }
+
+ @Test
+ fun `different explicit description and visible text are not within-target duplicates`() {
+ val bookItem = createNode(
+ composableName = "ClickableText",
+ contentDescription = "Book cover image",
+ textLabel = "Moby Dick, Herman Melville, English",
+ hasExplicitContentDescription = true,
+ isTouchTarget = true,
+ )
+
+ assertTrue(rule.evaluateAll(listOf(bookItem)).isEmpty())
+ }
+
@Test
fun `same description at different depths is not a duplicate`() {
val nodes = listOf(
@@ -84,6 +147,29 @@ class DuplicateContentDescriptionRuleTest {
assertEquals(3, rule.evaluateAll(nodes).size)
}
+ @Test
+ fun `same description and spoken text remain duplicates`() {
+ val nodes = listOf(
+ createNode(
+ bounds = Rect(0, 0, 100, 100),
+ contentDescription = "Book cover image",
+ textLabel = "Untitled book",
+ isTouchTarget = true,
+ ),
+ createNode(
+ bounds = Rect(0, 100, 100, 200),
+ contentDescription = "Book cover image",
+ textLabel = "Untitled book",
+ isTouchTarget = true,
+ ),
+ )
+
+ val issues = rule.evaluateAll(nodes)
+
+ assertEquals(2, issues.size)
+ assertTrue(issues.all { it.message.contains("'Book cover image, Untitled book'") })
+ }
+
// --- edge cases ---
@Test
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt
index dd91598..d3e69dc 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt
@@ -24,6 +24,8 @@ fun createNode(
parentNodeId: String? = null,
isEnabled: Boolean = true,
isCollectionContainer: Boolean = false,
+ textLabel: String? = null,
+ hasExplicitContentDescription: Boolean = false,
): A11yNode = A11yNode(
nodeId = nodeId,
composableName = composableName,
@@ -40,4 +42,6 @@ fun createNode(
parentNodeId = parentNodeId,
isEnabled = isEnabled,
isCollectionContainer = isCollectionContainer,
+ textLabel = textLabel,
+ hasExplicitContentDescription = hasExplicitContentDescription,
)
diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
index e4c680f..73b52ce 100644
--- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
+++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt
@@ -144,6 +144,32 @@ class FocusOrderRuleTest {
assertEquals(1, rule.evaluateAll(nodes).size)
}
+ @Test
+ fun `amount nested in a semantics wrapper is flagged after submit`() {
+ val form = createNode(nodeId = "form").copy(isTraversalGroup = true)
+ val wrapper = createNode(nodeId = "wrapper", parentNodeId = "form")
+ val submit = createNode(nodeId = "submit", parentNodeId = "form",
+ isFocusable = true, bounds = Rect(0, 400, 100, 456))
+ val amount = createNode(nodeId = "amount", parentNodeId = "wrapper",
+ isFocusable = true, bounds = Rect(0, 20, 100, 80))
+
+ assertEquals(listOf("amount"), rule.evaluateAll(listOf(form, submit, wrapper, amount)).map { it.affectedNode.nodeId })
+ assertTrue(rule.evaluateAll(listOf(form, wrapper, amount, submit)).isEmpty())
+ }
+
+ @Test
+ fun `separate traversal groups under the same root are not compared`() {
+ val root = createNode(nodeId = "root")
+ val content = createNode(nodeId = "content", parentNodeId = "root").copy(isTraversalGroup = true)
+ val appBar = createNode(nodeId = "app-bar", parentNodeId = "root").copy(isTraversalGroup = true)
+ val nodes = listOf(root, content,
+ createNode(parentNodeId = "content", isFocusable = true, bounds = Rect(0, 700, 100, 750)),
+ appBar,
+ createNode(parentNodeId = "app-bar", isFocusable = true, bounds = Rect(0, 20, 100, 70)))
+
+ assertTrue(rule.evaluateAll(nodes).isEmpty())
+ }
+
// --- failing cases ---
@Test
diff --git a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt
index 5d8fc01..89d806d 100644
--- a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt
+++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
@@ -33,6 +35,7 @@ class A11yNodeExtractorTest {
val clickableNode = nodes.single { it.isTouchTarget && it.contentDescription == "Go" }
assertEquals("ClickableText", clickableNode.composableName)
+ assertTrue(!clickableNode.hasExplicitContentDescription)
}
@Test
@@ -51,6 +54,29 @@ class A11yNodeExtractorTest {
assertTrue(clickableNode.contentDescription.isNullOrBlank())
}
+ @Test
+ fun explicitDescription_isDistinguishedFromTextFallback() {
+ composeRule.setContent {
+ Box(
+ modifier = Modifier
+ .semantics { contentDescription = "Home" }
+ .clickable { },
+ ) {
+ Text("Home")
+ }
+ }
+
+ composeRule.waitForIdle()
+
+ val nodes = A11yNodeExtractor()
+ .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
+ val clickableNode = nodes.single { it.isTouchTarget }
+
+ assertEquals("Home", clickableNode.contentDescription)
+ assertEquals("Home", clickableNode.textLabel)
+ assertTrue(clickableNode.hasExplicitContentDescription)
+ }
+
@Test
fun compactClickable_extractsExpandedEffectiveTouchBounds() {
composeRule.setContent {
diff --git a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/InspectionModeToggleTest.kt b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/InspectionModeToggleTest.kt
new file mode 100644
index 0000000..145e9eb
--- /dev/null
+++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/InspectionModeToggleTest.kt
@@ -0,0 +1,44 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertExists
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
+import org.junit.Rule
+import org.junit.Test
+
+class InspectionModeToggleTest {
+
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun toggle_switchesBetweenInteractionAndInspectionActions() {
+ var inspectionEnabled by mutableStateOf(true)
+ composeRule.setContent {
+ MaterialTheme {
+ InspectionModeToggle(
+ inspectionEnabled = inspectionEnabled,
+ onInspectionEnabledChange = { inspectionEnabled = it },
+ )
+ }
+ }
+
+ composeRule
+ .onNodeWithContentDescription("Interact with app")
+ .performClick()
+
+ composeRule
+ .onNodeWithContentDescription("Resume issue inspection")
+ .assertExists()
+ .performClick()
+
+ composeRule
+ .onNodeWithContentDescription("Interact with app")
+ .assertExists()
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
index a1d716e..76c9e51 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
@@ -2,59 +2,29 @@ package com.composea11yscanner
import android.content.Context
import android.content.pm.ApplicationInfo
-import android.os.Looper
-import android.util.Log
-import android.view.View
import android.view.ViewGroup
-import android.view.ViewTreeObserver
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.activity.ComponentActivity
-import androidx.annotation.MainThread
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
-import androidx.compose.ui.semantics.SemanticsOwner
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import com.composea11yscanner.core.model.A11yIssue
-import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.core.model.ScannerState
import com.composea11yscanner.rules.ScannerRules
-import com.composea11yscanner.ui.A11yIssueOverlay
-import com.composea11yscanner.ui.A11yNodeExtractor
import com.composea11yscanner.ui.A11yScannerController
-import com.composea11yscanner.ui.IssueDetailPanel
-import com.composea11yscanner.ui.ReadinessFingerprint
-import com.composea11yscanner.ui.RenderedTextContrastAnalyzer
-import com.composea11yscanner.ui.ScanSummaryBar
-import com.composea11yscanner.ui.ScreenFingerprint
-import com.composea11yscanner.ui.calculateReadinessFingerprint
-import com.composea11yscanner.ui.calculateScreenFingerprint
+import com.composea11yscanner.ui.AutoScanCoordinator
+import com.composea11yscanner.ui.ComposeHostFinder
+import com.composea11yscanner.ui.ComposeNodeProvider
+import com.composea11yscanner.ui.ScannerOverlayContent
+import com.composea11yscanner.ui.ScreenSnapshotProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
-import android.graphics.Rect as AndroidRect
+import android.os.Looper
+import androidx.annotation.MainThread
/**
* Top-level public API for the Compose Accessibility Scanner.
@@ -83,8 +53,8 @@ import android.graphics.Rect as AndroidRect
*/
object ComposeA11yScanner {
- private const val COMPOSE_HOST_LOG_TAG = "ComposeA11yHosts"
- private const val SCAN_LIFECYCLE_LOG_TAG = "ComposeA11yLifecycle"
+ private const val TEXT_CONTRAST_RULE_ID = "text-contrast"
+
/**
* Active scanner entries keyed by activity. [LinkedHashMap] preserves insertion order so
@@ -92,7 +62,7 @@ object ComposeA11yScanner {
*
* Must only be read/written on the main thread.
*/
- private val entries = LinkedHashMap()
+ private val entries = LinkedHashMap()
/** Set during [install] so that [scan] can perform the permission check without a [Context]. */
@Volatile private var cachedAppContext: Context? = null
@@ -172,37 +142,49 @@ object ComposeA11yScanner {
cachedAppContext = activity.applicationContext
+ var overlayView: ComposeView? = null
+ val hostFinder = ComposeHostFinder()
+ val nodes = ComposeNodeProvider(activity, { overlayView }, hostFinder)
+ val snapshots = ScreenSnapshotProvider(
+ activity = activity,
+ overlayViewProvider = { overlayView },
+ destinationKeyProvider = destinationKeyProvider,
+ hostFinder = hostFinder,
+ )
val controller = A11yScannerController(
- nodeProvider = { extractNodes(activity) },
+ nodeProvider = nodes::mergedNodes,
screenDensity = activity.resources.displayMetrics.density,
+ ruleNodeOverridesProvider = {
+ if (TEXT_CONTRAST_RULE_ID in config.enabledRules) {
+ mapOf(TEXT_CONTRAST_RULE_ID to nodes.contrastNodes())
+ } else {
+ emptyMap()
+ }
+ },
).configure(config)
- val overlayView = ComposeView(activity).also { view ->
+ overlayView = ComposeView(activity).also { view ->
view.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnLifecycleDestroyed(activity),
)
view.setContent {
- MaterialTheme {
- ScannerOverlayContent(controller = controller, config = config)
- }
+ MaterialTheme { ScannerOverlayContent(controller, config) }
}
}
activity.addContentView(overlayView, ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT))
val observer = AutoUninstallObserver(activity)
- val entry = InstallEntry(
+ val coordinator = AutoScanCoordinator(
controller = controller,
overlayView = overlayView,
automatic = automatic,
autoScan = config.autoScan,
- screenSnapshotProvider = {
- activity.currentScreenSnapshot(destinationKeyProvider)
- },
+ screenSnapshotProvider = snapshots::current,
removeObserver = { activity.lifecycle.removeObserver(observer) },
)
- entries[activity] = entry
- entry.attach()
- activeController.value = controller
+ entries[activity] = coordinator
+ coordinator.attach()
+ routeActive()
activity.lifecycle.addObserver(observer)
}
@@ -233,10 +215,10 @@ object ComposeA11yScanner {
activeController.value = activeEntry()?.controller
}
- private fun activeEntry(): InstallEntry? = selectEntry(
+ private fun activeEntry(): AutoScanCoordinator? = selectEntry(
resumedActivities = scannerLifecycle.resumedActivities(),
entries = entries,
- isAutomatic = InstallEntry::automatic,
+ isAutomatic = AutoScanCoordinator::automatic,
)
/**
@@ -356,489 +338,11 @@ object ComposeA11yScanner {
requireDebugBuild(ctx)
}
- // ── Node extraction ──────────────────────────────────────────────────────────
-
- // nodeProvider is invoked from Dispatchers.Default (inside A11yScannerController).
- // Reading the decor-view hierarchy and SemanticsOwner from a background thread is safe for
- // this debug tool: view-hierarchy reads do not trigger layout/draw callbacks, and the
- // Compose semantics snapshot is immutable once produced on the main thread.
- // runCatching provides a last-resort safety net in case of unexpected threading issues.
- private fun extractNodes(activity: ComponentActivity): List =
- runCatching { extractNodesUnchecked(activity) }
- .onFailure { error ->
- Log.e(COMPOSE_HOST_LOG_TAG, "Failed to extract Compose semantics", error)
- }
- .getOrDefault(emptyList())
-
- private fun extractNodesUnchecked(activity: ComponentActivity): List {
- val overlayView = entries[activity]?.overlayView
- val decorView = activity.window.decorView as? ViewGroup ?: return emptyList()
- decorView.logAbstractComposeViews(excludeView = overlayView)
- val selectedHost = decorView
- .findBestAbstractComposeView(excludeView = overlayView)
- ?: return emptyList()
- Log.d(
- COMPOSE_HOST_LOG_TAG,
- "Selected host: ${selectedHost.view.composeHostDescription(isExcluded = false)}, " +
- "visibleTextNodes=${selectedHost.visibleTextNodes}, " +
- "visibleNodes=${selectedHost.visibleNodes}, depth=${selectedHost.depth}",
- )
- val semanticNodes = selectedHost.nodes
- return runCatching {
- RenderedTextContrastAnalyzer(selectedHost.view).analyze(semanticNodes)
- }.onFailure { error ->
- // Rendered contrast is an optional enrichment step. A bitmap capture or pixel-analysis
- // failure must not discard the semantics tree and turn the whole scan into an empty
- // 100% result; all non-visual rules can still evaluate the original nodes.
- Log.w(
- COMPOSE_HOST_LOG_TAG,
- "Rendered text contrast analysis failed; scanning semantic nodes without colors",
- error,
- )
- }.getOrDefault(semanticNodes)
- }
-
- private fun ComponentActivity.currentScreenSnapshot(
- destinationKeyProvider: (() -> String?)?,
- ): ScreenSnapshot? {
- val overlayView = entries[this]?.overlayView
- val decorView = window.decorView as? ViewGroup ?: return null
- val candidate = decorView
- .findBestAbstractComposeView(excludeView = overlayView, logScores = false)
- ?: return null
- // A newly attached ComposeView can expose only its root node before the destination has
- // produced semantics. Treat that state as not ready instead of reporting an empty 100% scan.
- if (candidate.nodes.none { it.depth > 0 }) return null
- val destinationKey = destinationKeyProvider?.let { provider ->
- runCatching(provider)
- .onFailure { error ->
- Log.w(SCAN_LIFECYCLE_LOG_TAG, "Destination key provider failed", error)
- }
- .getOrNull()
- }
- return ScreenSnapshot(
- fingerprint = candidate.screenFingerprint(destinationKey),
- readiness = candidate.readinessFingerprint(),
- )
- }
-
- private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? {
- val composeOwnerView = getChildAt(0) ?: return null
- return runCatching {
- composeOwnerView.javaClass
- .getMethod("getSemanticsOwner")
- .invoke(composeOwnerView) as? SemanticsOwner
- }.getOrNull()
- }
-
- private fun ViewGroup.findBestAbstractComposeView(
- excludeView: View?,
- logScores: Boolean = true,
- ): ComposeHostCandidate? {
- val candidates = mutableListOf()
- collectComposeHostCandidates(
- excludeView = excludeView,
- depth = 0,
- candidates = candidates,
- )
- if (logScores) {
- candidates.forEach { candidate ->
- Log.d(
- COMPOSE_HOST_LOG_TAG,
- "Candidate score: identity=${System.identityHashCode(candidate.view)}, " +
- "visibleTextNodes=${candidate.visibleTextNodes}, " +
- "visibleNodes=${candidate.visibleNodes}, depth=${candidate.depth}",
- )
- }
- }
- return candidates.maxWithOrNull(
- compareBy { it.visibleTextNodes }
- .thenBy { it.visibleNodes }
- .thenBy { it.depth },
- )
- }
-
- private fun ViewGroup.collectComposeHostCandidates(
- excludeView: View?,
- depth: Int,
- candidates: MutableList,
- ) {
- for (index in 0 until childCount) {
- val child = getChildAt(index)
- if (child is AbstractComposeView && child !== excludeView && child.isViableComposeHost()) {
- child.toComposeHostCandidate(depth + 1)?.let(candidates::add)
- }
- if (child is ViewGroup && child !== excludeView) {
- child.collectComposeHostCandidates(
- excludeView = excludeView,
- depth = depth + 1,
- candidates = candidates,
- )
- }
- }
- }
-
- private fun AbstractComposeView.isViableComposeHost(): Boolean =
- visibility == View.VISIBLE &&
- isShown &&
- isAttachedToWindow &&
- isLaidOut &&
- alpha > 0f &&
- width > 0 &&
- height > 0
-
- private fun AbstractComposeView.toComposeHostCandidate(depth: Int): ComposeHostCandidate? {
- val owner = findSemanticsOwner() ?: return null
- val nodes = runCatching { A11yNodeExtractor().extract(owner) }.getOrNull() ?: return null
- val visibleNodes = nodes.filter { node -> node.bounds.intersectsViewport(width, height) }
- return ComposeHostCandidate(
- view = this,
- nodes = nodes,
- depth = depth,
- visibleSemanticNodes = visibleNodes,
- )
- }
-
- private fun ViewGroup.logAbstractComposeViews(
- excludeView: View?,
- path: String = javaClass.simpleName,
- ) {
- for (index in 0 until childCount) {
- val child = getChildAt(index)
- val childPath = "$path/$index:${child.javaClass.simpleName}"
- if (child is AbstractComposeView) {
- Log.d(
- COMPOSE_HOST_LOG_TAG,
- "Candidate path=$childPath, " +
- child.composeHostDescription(isExcluded = child === excludeView),
- )
- }
- if (child is ViewGroup) {
- child.logAbstractComposeViews(
- excludeView = excludeView,
- path = childPath,
- )
- }
- }
- }
-
- private fun AbstractComposeView.composeHostDescription(isExcluded: Boolean): String {
- val screenLocation = IntArray(2)
- getLocationOnScreen(screenLocation)
- val visibleRect = AndroidRect()
- val hasVisibleRect = getGlobalVisibleRect(visibleRect)
- return "identity=${System.identityHashCode(this)}, " +
- "excludedOverlay=$isExcluded, " +
- "visibility=${visibility.asVisibilityName()}, " +
- "shown=$isShown, attached=$isAttachedToWindow, laidOut=$isLaidOut, " +
- "alpha=$alpha, size=${width}x$height, " +
- "position=($x,$y), translation=($translationX,$translationY), " +
- "screen=(${screenLocation[0]},${screenLocation[1]}), " +
- "hasVisibleRect=$hasVisibleRect, visibleRect=$visibleRect, " +
- "childCount=$childCount"
- }
-
- private fun Int.asVisibilityName(): String = when (this) {
- View.VISIBLE -> "VISIBLE"
- View.INVISIBLE -> "INVISIBLE"
- View.GONE -> "GONE"
- else -> toString()
- }
-
- private fun com.composea11yscanner.core.model.Rect.intersectsViewport(
- viewportWidth: Int,
- viewportHeight: Int,
- ): Boolean =
- !isEmpty() &&
- right > 0 &&
- bottom > 0 &&
- left < viewportWidth &&
- top < viewportHeight
-
- // ── Inner types ──────────────────────────────────────────────────────────────
-
- private class InstallEntry(
- val controller: A11yScannerController,
- val overlayView: ComposeView,
- var automatic: Boolean,
- private val autoScan: Boolean,
- private val screenSnapshotProvider: () -> ScreenSnapshot?,
- private val removeObserver: () -> Unit,
- ) : ViewTreeObserver.OnPreDrawListener {
- private var baselineFingerprint: ScreenFingerprint? = null
- private var completedScanId: String? = null
- private var lastCheckUptimeMillis = 0L
- private var pendingScreenFingerprint: ScreenFingerprint? = null
- private var rescanRequestedAtUptimeMillis: Long? = null
- private var pendingInitialReadiness: ReadinessFingerprint? = null
- private var initialScanRequestedAtUptimeMillis: Long? = null
- private val initialScanRunnable = object : Runnable {
- override fun run() {
- val now = android.os.SystemClock.uptimeMillis()
- val deadlineReached = initialScanRequestedAtUptimeMillis?.let { requestedAt ->
- now - requestedAt >= MAX_INITIAL_SETTLE_MILLIS
- } ?: true
- val snapshot = screenSnapshotProvider()
- if (snapshot == null && !deadlineReached) return scheduleInitialScanCheck()
-
- val readiness = snapshot?.readiness
- if (readiness != null && readiness != pendingInitialReadiness && !deadlineReached) {
- pendingInitialReadiness = readiness
- Log.d(
- SCAN_LIFECYCLE_LOG_TAG,
- "Initial semantics changed; waiting for a stable sample: $readiness",
- )
- return scheduleInitialScanCheck()
- }
-
- Log.d(
- SCAN_LIFECYCLE_LOG_TAG,
- if (deadlineReached) {
- "Initial settle deadline reached; starting scan"
- } else {
- "Initial host ready; starting scan"
- },
- )
- pendingInitialReadiness = null
- initialScanRequestedAtUptimeMillis = null
- controller.startScan()
- }
- }
- private val rescanRunnable = object : Runnable {
- override fun run() {
- val expectedFingerprint = pendingScreenFingerprint ?: return
- val now = android.os.SystemClock.uptimeMillis()
- val deadlineReached = rescanRequestedAtUptimeMillis?.let { requestedAt ->
- now - requestedAt >= MAX_RESCAN_SETTLE_MILLIS
- } ?: true
- val currentFingerprint = screenSnapshotProvider()?.fingerprint
-
- if (currentFingerprint == null && !deadlineReached) return scheduleRescan()
- if (
- currentFingerprint != null &&
- currentFingerprint != expectedFingerprint &&
- !deadlineReached
- ) {
- pendingScreenFingerprint = currentFingerprint
- return scheduleRescan()
- }
-
- Log.d(
- SCAN_LIFECYCLE_LOG_TAG,
- if (deadlineReached) {
- "Rescan settle deadline reached; starting scan"
- } else {
- "Destination stable; starting rescan"
- },
- )
- pendingScreenFingerprint = null
- rescanRequestedAtUptimeMillis = null
- controller.startScan()
- }
- }
-
- fun attach() {
- overlayView.rootView.viewTreeObserver.addOnPreDrawListener(this)
- if (autoScan) requestInitialScan()
- }
-
- override fun onPreDraw(): Boolean {
- val now = android.os.SystemClock.uptimeMillis()
- if (now - lastCheckUptimeMillis < SCREEN_CHECK_INTERVAL_MILLIS) return true
- lastCheckUptimeMillis = now
-
- val complete = controller.currentState as? ScannerState.Complete
- if (complete == null) {
- completedScanId = null
- baselineFingerprint = null
- return true
- }
-
- val currentFingerprint = screenSnapshotProvider()?.fingerprint ?: return true
- if (completedScanId != complete.result.scanId) {
- completedScanId = complete.result.scanId
- baselineFingerprint = currentFingerprint
- Log.d(SCAN_LIFECYCLE_LOG_TAG, "Scan baseline recorded: $currentFingerprint")
- return true
- }
-
- if (baselineFingerprint != currentFingerprint) {
- Log.d(
- SCAN_LIFECYCLE_LOG_TAG,
- "Screen changed: previous=$baselineFingerprint, current=$currentFingerprint",
- )
- baselineFingerprint = null
- completedScanId = null
- controller.clearState()
- if (autoScan) {
- pendingScreenFingerprint = currentFingerprint
- rescanRequestedAtUptimeMillis = android.os.SystemClock.uptimeMillis()
- scheduleRescan()
- }
- }
- return true
- }
-
- private fun scheduleRescan() {
- overlayView.removeCallbacks(rescanRunnable)
- overlayView.postDelayed(rescanRunnable, RESCAN_SETTLE_DELAY_MILLIS)
- }
-
- private fun requestInitialScan() {
- pendingInitialReadiness = screenSnapshotProvider()?.readiness
- initialScanRequestedAtUptimeMillis = android.os.SystemClock.uptimeMillis()
- scheduleInitialScanCheck()
- }
-
- fun notifyScreenChanged() {
- Log.d(SCAN_LIFECYCLE_LOG_TAG, "Screen change explicitly notified")
- baselineFingerprint = null
- completedScanId = null
- controller.clearState()
- if (!autoScan) return
-
- val currentFingerprint = screenSnapshotProvider()?.fingerprint
- if (currentFingerprint == null) {
- requestInitialScan()
- } else {
- pendingScreenFingerprint = currentFingerprint
- rescanRequestedAtUptimeMillis = android.os.SystemClock.uptimeMillis()
- scheduleRescan()
- }
- }
-
- private fun scheduleInitialScanCheck() {
- overlayView.removeCallbacks(initialScanRunnable)
- overlayView.postDelayed(initialScanRunnable, RESCAN_SETTLE_DELAY_MILLIS)
- }
-
- fun detach() {
- removeObserver()
- val observer = overlayView.rootView.viewTreeObserver
- if (observer.isAlive) observer.removeOnPreDrawListener(this)
- overlayView.removeCallbacks(initialScanRunnable)
- overlayView.removeCallbacks(rescanRunnable)
- pendingScreenFingerprint = null
- rescanRequestedAtUptimeMillis = null
- pendingInitialReadiness = null
- initialScanRequestedAtUptimeMillis = null
- overlayView.disposeComposition()
- (overlayView.parent as? ViewGroup)?.removeView(overlayView)
- controller.stopScan()
- controller.destroy()
- }
-
- private companion object {
- const val SCREEN_CHECK_INTERVAL_MILLIS = 500L
- const val RESCAN_SETTLE_DELAY_MILLIS = 300L
- const val MAX_RESCAN_SETTLE_MILLIS = 1_500L
- const val MAX_INITIAL_SETTLE_MILLIS = 1_500L
- }
- }
-
- private data class ComposeHostCandidate(
- val view: AbstractComposeView,
- val nodes: List,
- val depth: Int,
- val visibleSemanticNodes: List,
- ) {
- val visibleTextNodes: Int
- get() = visibleSemanticNodes.count { it.composableName == "Text" }
-
- val visibleNodes: Int
- get() = visibleSemanticNodes.size
-
- fun screenFingerprint(destinationKey: String?): ScreenFingerprint {
- return calculateScreenFingerprint(
- hostIdentity = System.identityHashCode(view),
- nodes = nodes,
- destinationKey = destinationKey,
- )
- }
-
- fun readinessFingerprint(): ReadinessFingerprint {
- return calculateReadinessFingerprint(
- hostIdentity = System.identityHashCode(view),
- visibleNodes = visibleSemanticNodes,
- )
- }
- }
-
- private data class ScreenSnapshot(
- val fingerprint: ScreenFingerprint,
- val readiness: ReadinessFingerprint,
- )
-
private class AutoUninstallObserver(
private val activity: ComponentActivity,
) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
- // entries[activity] may already be null if uninstall() was called manually first.
remove(activity)
}
}
-}
-
-// ── Overlay composable ──────────────────────────────────────────────────────────
-
-/**
- * Internal composable rendered inside the overlay [ComposeView] that [ComposeA11yScanner.install]
- * adds on top of the activity's content. Mirrors the layer structure of
- * [com.composea11yscanner.ui.A11yScannerScaffold]
- * without re-wrapping the host content.
- */
-@Composable
-private fun ScannerOverlayContent(
- controller: A11yScannerController,
- config: ScannerConfig,
-) {
- var scannerState by remember { mutableStateOf(ScannerState.Idle) }
- var selectedIssues by remember { mutableStateOf(emptyList()) }
-
- DisposableEffect(Unit) { onDispose { controller.stopScan() } }
-
- LaunchedEffect(Unit) {
- controller.stateFlow.collect { state ->
- scannerState = state
- if (state !is ScannerState.Complete) selectedIssues = emptyList()
- }
- }
-
- LaunchedEffect(config) {
- controller.configure(config)
- if (!config.autoScan) {
- controller.clearState()
- }
- }
-
- val scanResult = (scannerState as? ScannerState.Complete)?.result
-
- Box(modifier = Modifier.fillMaxSize()) {
- A11yIssueOverlay(
- scanResult = scanResult,
- onIssuesSelected = { selectedIssues = it },
- modifier = Modifier.fillMaxSize(),
- )
-
- AnimatedVisibility(
- visible = scannerState !is ScannerState.Idle,
- enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
- modifier = Modifier
- .align(Alignment.TopCenter)
- .statusBarsPadding()
- .fillMaxWidth(),
- ) {
- ScanSummaryBar(
- state = scannerState,
- modifier = Modifier.fillMaxWidth(),
- )
- }
-
- IssueDetailPanel(
- issues = selectedIssues,
- onDismiss = { selectedIssues = emptyList() },
- modifier = Modifier.align(Alignment.BottomCenter),
- )
- }
-}
+}
\ No newline at end of file
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
index ac10423..1658f76 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
@@ -101,6 +101,10 @@ class A11yNodeExtractor {
} else {
null
}
+ val explicitContentDescription = config
+ .getOrNull(SemanticsProperties.ContentDescription)
+ ?.joinToString(separator = ", ")
+ ?.takeIf(String::isNotBlank)
val visualBounds = boundsInRoot
val bounds = visualBounds.toCoreRect()
@@ -112,10 +116,9 @@ class A11yNodeExtractor {
textLabel = textLabel,
),
bounds = bounds,
- contentDescription = config
- .getOrNull(SemanticsProperties.ContentDescription)
- ?.joinToString(separator = ", ")
- ?: textLabel,
+ contentDescription = explicitContentDescription ?: textLabel,
+ textLabel = textLabel,
+ hasExplicitContentDescription = explicitContentDescription != null,
isTouchTarget = isTouchTarget,
textColor = null, // not available via semantics
backgroundColors = emptyList(), // not available via semantics
@@ -132,6 +135,13 @@ class A11yNodeExtractor {
parentNodeId = parentNodeId,
isEnabled = isEnabled,
isCollectionContainer = config.contains(SemanticsProperties.CollectionInfo),
+ isTraversalGroup = config.getOrNull(SemanticsProperties.IsTraversalGroup) == true,
+ unclippedBounds = Rect(
+ positionInRoot.x.roundToInt(),
+ positionInRoot.y.roundToInt(),
+ (positionInRoot.x + size.width).roundToInt(),
+ (positionInRoot.y + size.height).roundToInt(),
+ ),
)
}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
index 0ee443c..301311a 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
@@ -1,7 +1,9 @@
package com.composea11yscanner.ui
+import android.util.Log
import com.composea11yscanner.core.A11yScanEngine
import com.composea11yscanner.core.model.A11yNode
+import com.composea11yscanner.core.model.ScanResult
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.core.model.ScannerState
import com.composea11yscanner.core.rule.A11yRule
@@ -40,14 +42,23 @@ import java.util.concurrent.atomic.AtomicLong
* controller.destroy() // cancel the internal scope when the host is destroyed
* ```
*
- * @param nodeProvider Called once per [startScan] invocation to produce the node list.
- * Must be safe to call on [Dispatchers.Default].
+ * @param nodeProvider Called once per [startScan] invocation on the main dispatcher to produce the
+ * node list. It may suspend while rendered pixels are copied from the application window.
+ * @param ruleNodeOverridesProvider Optionally supplies specialized nodes for individual rules.
+ * Rules without an override continue to evaluate [nodeProvider]'s accessibility tree.
* @param screenDensity DisplayMetrics.density, forwarded to density-dependent rules.
*/
class A11yScannerController(
- private val nodeProvider: () -> List,
+ private val nodeProvider: suspend () -> List,
private val screenDensity: Float,
+ private val ruleNodeOverridesProvider: suspend () -> Map> = {
+ emptyMap()
+ },
) {
+ private companion object {
+ const val SCAN_RESULT_LOG_TAG = "ComposeA11yResults"
+ }
+
@Volatile
internal var currentState: ScannerState = ScannerState.Idle
private set
@@ -146,9 +157,14 @@ class A11yScannerController(
_state.emit(state)
return@launch
}
- engine.scan(nodes).collect { state ->
+ val ruleNodeOverrides = withContext(Dispatchers.Main.immediate) {
+ ruleNodeOverridesProvider()
+ }
+ if (generation != scanGeneration.get()) return@launch
+ engine.scan(nodes, ruleNodeOverrides).collect { state ->
if (generation != scanGeneration.get()) return@collect
currentState = state
+ if (state is ScannerState.Complete) logScanResult(state.result)
_state.emit(state)
}
}
@@ -175,6 +191,27 @@ class A11yScannerController(
scanJob = null
}
+ private fun logScanResult(result: ScanResult) {
+ Log.d(
+ SCAN_RESULT_LOG_TAG,
+ "Scan complete: id=${result.scanId}, nodes=${result.totalNodes}, " +
+ "score=${result.overallScore.toInt()}%, issues=${result.issues.size} " +
+ "(errors=${result.errorCount}, warnings=${result.warningCount}, " +
+ "info=${result.infoCount}), rulesPassed=${result.passedRules}, " +
+ "rulesFailed=${result.failedRules}",
+ )
+ result.issues.forEachIndexed { index, issue ->
+ Log.d(
+ SCAN_RESULT_LOG_TAG,
+ "Issue ${index + 1}/${result.issues.size}: severity=${issue.severity}, " +
+ "rule=${issue.ruleId} (${issue.ruleName}), " +
+ "node=${issue.affectedNode.composableName}#${issue.affectedNode.nodeId}, " +
+ "bounds=${issue.affectedNode.bounds}, message=${issue.message}, " +
+ "fix=${issue.howToFix}, wcag=${issue.wcagReference ?: "n/a"}",
+ )
+ }
+ }
+
/** Cancels the internal [CoroutineScope]. Call when the host (Activity/Fragment/ViewModel) is destroyed. */
fun destroy() {
stopScan()
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
index 72b8097..23c7c51 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
@@ -56,6 +57,7 @@ import com.composea11yscanner.core.model.ScannerState
* @param issueOffsetY Vertical offset applied to issue highlights.
* @param summaryBarTopOffset Additional distance between the status bar and the scan summary.
* Use this when host content has a top app bar that must remain visible while scan results are shown.
+ * @param inspectionToggleBottomOffset Extra space above host bottom navigation for the toggle.
* @param content Host UI content being scanned.
*/
@Composable
@@ -65,10 +67,12 @@ fun A11yScannerScaffold(
modifier: Modifier = Modifier,
issueOffsetY: Int = 0,
summaryBarTopOffset: Dp = 0.dp,
+ inspectionToggleBottomOffset: Dp = 0.dp,
content: @Composable () -> Unit,
) {
var scannerState by remember { mutableStateOf(ScannerState.Idle) }
var selectedIssues by remember { mutableStateOf(emptyList()) }
+ var inspectionEnabled by remember { mutableStateOf(true) }
// Cancel any in-flight scan when the scaffold leaves composition.
DisposableEffect(Unit) {
@@ -78,7 +82,10 @@ fun A11yScannerScaffold(
LaunchedEffect(Unit) {
scannerController.stateFlow.collect { state ->
scannerState = state
- if (state !is ScannerState.Complete) selectedIssues = emptyList()
+ if (state !is ScannerState.Complete) {
+ selectedIssues = emptyList()
+ inspectionEnabled = true
+ }
}
}
@@ -100,7 +107,7 @@ fun A11yScannerScaffold(
// ── 2. Issue highlight overlay ───────────────────────────────────────
A11yIssueOverlay(
- scanResult = scanResult,
+ scanResult = scanResult.takeIf { inspectionEnabled },
onIssuesSelected = { selectedIssues = it },
modifier = Modifier.fillMaxSize(),
issueOffsetY = issueOffsetY,
@@ -108,7 +115,7 @@ fun A11yScannerScaffold(
// ── 3. Summary bar — slides down from the top once scanning starts ───
AnimatedVisibility(
- visible = scannerState !is ScannerState.Idle,
+ visible = scannerState !is ScannerState.Idle && inspectionEnabled,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
modifier = Modifier
@@ -125,10 +132,29 @@ fun A11yScannerScaffold(
// ── 4. Issue detail panel — slides up when an overlay box is tapped ──
IssueDetailPanel(
- issues = selectedIssues,
+ issues = selectedIssues.takeIf { inspectionEnabled }.orEmpty(),
onDismiss = { selectedIssues = emptyList() },
modifier = Modifier.align(Alignment.BottomCenter),
)
+
+ AnimatedVisibility(
+ visible = scanResult != null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .navigationBarsPadding()
+ .padding(bottom = inspectionToggleBottomOffset)
+ .padding(16.dp),
+ ) {
+ InspectionModeToggle(
+ inspectionEnabled = inspectionEnabled,
+ onInspectionEnabledChange = { enabled ->
+ inspectionEnabled = enabled
+ if (!enabled) selectedIssues = emptyList()
+ },
+ )
+ }
}
}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/AutoScanCoordinator.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/AutoScanCoordinator.kt
new file mode 100644
index 0000000..c2a28c7
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/AutoScanCoordinator.kt
@@ -0,0 +1,196 @@
+package com.composea11yscanner.ui
+
+import android.os.SystemClock
+import android.util.Log
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import androidx.compose.ui.platform.ComposeView
+import com.composea11yscanner.core.model.ScannerState
+
+/** Coordinates initial scans and stable rescans independently of installation and UI rendering. */
+internal class AutoScanCoordinator(
+ val controller: A11yScannerController,
+ val overlayView: ComposeView,
+ var automatic: Boolean,
+ private val autoScan: Boolean,
+ private val screenSnapshotProvider: () -> ScreenSnapshot?,
+ private val removeObserver: () -> Unit,
+) : ViewTreeObserver.OnPreDrawListener {
+ private var baselineFingerprint: ScreenFingerprint? = null
+ private var completedScanId: String? = null
+ private var lastCheckUptimeMillis = 0L
+ private var initialVerificationSnapshot: ScreenSnapshot? = null
+ private var rescanVerificationSnapshot: ScreenSnapshot? = null
+ private val initialStabilityTracker = newStabilityTracker()
+ private val rescanStabilityTracker = newStabilityTracker()
+
+ private val initialScanRunnable = Runnable {
+ val now = SystemClock.uptimeMillis()
+ val snapshot = screenSnapshotProvider() ?: run {
+ resetInitialStability()
+ scheduleInitialScanCheck()
+ return@Runnable
+ }
+ val verification = initialVerificationSnapshot
+ if (verification != null) {
+ if (snapshot == verification) {
+ Log.d(LOG_TAG, "Initial host stable; starting scan")
+ resetInitialStability()
+ controller.startScan()
+ return@Runnable
+ }
+ Log.d(LOG_TAG, "Initial semantics changed during verification; settling again: ${snapshot.readiness}")
+ resetInitialStability()
+ initialStabilityTracker.observe(snapshot.readiness, now)
+ scheduleInitialScanCheck()
+ return@Runnable
+ }
+ if (initialStabilityTracker.observe(snapshot.readiness, now)) {
+ initialVerificationSnapshot = snapshot
+ Log.d(LOG_TAG, "Initial semantics stable; verifying once more before scanning")
+ } else {
+ Log.d(LOG_TAG, "Waiting for sustained initial semantic stability: ${snapshot.readiness}")
+ }
+ scheduleInitialScanCheck()
+ }
+
+ private val rescanRunnable = Runnable {
+ val now = SystemClock.uptimeMillis()
+ val snapshot = screenSnapshotProvider() ?: run {
+ resetRescanStability()
+ scheduleRescan()
+ return@Runnable
+ }
+ val verification = rescanVerificationSnapshot
+ if (verification != null) {
+ if (snapshot == verification) {
+ Log.d(LOG_TAG, "Destination stable; starting rescan")
+ resetRescanStability()
+ controller.startScan()
+ return@Runnable
+ }
+ Log.d(LOG_TAG, "Rescan semantics changed during verification; settling again: ${snapshot.readiness}")
+ resetRescanStability()
+ rescanStabilityTracker.observe(snapshot.readiness, now)
+ scheduleRescan()
+ return@Runnable
+ }
+ if (rescanStabilityTracker.observe(snapshot.readiness, now)) {
+ rescanVerificationSnapshot = snapshot
+ Log.d(LOG_TAG, "Rescan semantics stable; verifying once more before scanning")
+ } else {
+ Log.d(LOG_TAG, "Waiting for sustained rescan semantic stability: ${snapshot.readiness}")
+ }
+ scheduleRescan()
+ }
+
+ fun attach() {
+ overlayView.rootView.viewTreeObserver.addOnPreDrawListener(this)
+ if (autoScan) requestInitialScan()
+ }
+
+ override fun onPreDraw(): Boolean {
+ val now = SystemClock.uptimeMillis()
+ if (now - lastCheckUptimeMillis < SCREEN_CHECK_INTERVAL_MILLIS) return true
+ lastCheckUptimeMillis = now
+ val complete = controller.currentState as? ScannerState.Complete
+ if (complete == null) {
+ completedScanId = null
+ baselineFingerprint = null
+ return true
+ }
+ val currentSnapshot = screenSnapshotProvider() ?: return true
+ if (completedScanId != complete.result.scanId) {
+ completedScanId = complete.result.scanId
+ baselineFingerprint = currentSnapshot.fingerprint
+ Log.d(LOG_TAG, "Scan baseline recorded: ${currentSnapshot.fingerprint}")
+ return true
+ }
+ if (baselineFingerprint != currentSnapshot.fingerprint) {
+ Log.d(LOG_TAG, "Screen changed: previous=$baselineFingerprint, current=${currentSnapshot.fingerprint}")
+ baselineFingerprint = null
+ completedScanId = null
+ controller.clearState()
+ if (autoScan) {
+ resetRescanStability()
+ rescanStabilityTracker.observe(currentSnapshot.readiness, now)
+ scheduleRescan()
+ }
+ }
+ return true
+ }
+
+ fun notifyScreenChanged() {
+ Log.d(LOG_TAG, "Screen change explicitly notified")
+ baselineFingerprint = null
+ completedScanId = null
+ controller.clearState()
+ if (!autoScan) return
+ val snapshot = screenSnapshotProvider()
+ if (snapshot == null) requestInitialScan() else {
+ resetRescanStability()
+ rescanStabilityTracker.observe(snapshot.readiness, SystemClock.uptimeMillis())
+ scheduleRescan()
+ }
+ }
+
+ fun detach() {
+ removeObserver()
+ val observer = overlayView.rootView.viewTreeObserver
+ if (observer.isAlive) observer.removeOnPreDrawListener(this)
+ overlayView.removeCallbacks(initialScanRunnable)
+ overlayView.removeCallbacks(rescanRunnable)
+ resetInitialStability()
+ resetRescanStability()
+ overlayView.disposeComposition()
+ (overlayView.parent as? ViewGroup)?.removeView(overlayView)
+ controller.stopScan()
+ controller.destroy()
+ }
+
+ private fun requestInitialScan() {
+ resetInitialStability()
+ screenSnapshotProvider()?.readiness?.let {
+ initialStabilityTracker.observe(it, SystemClock.uptimeMillis())
+ }
+ scheduleInitialScanCheck()
+ }
+
+ private fun resetInitialStability() {
+ initialVerificationSnapshot = null
+ initialStabilityTracker.reset()
+ }
+
+ private fun resetRescanStability() {
+ rescanVerificationSnapshot = null
+ rescanStabilityTracker.reset()
+ }
+
+ private fun scheduleInitialScanCheck() {
+ overlayView.removeCallbacks(initialScanRunnable)
+ overlayView.postDelayed(initialScanRunnable, SETTLE_DELAY_MILLIS)
+ }
+
+ private fun scheduleRescan() {
+ overlayView.removeCallbacks(rescanRunnable)
+ overlayView.postDelayed(rescanRunnable, SETTLE_DELAY_MILLIS)
+ }
+
+ private companion object {
+ const val LOG_TAG = "ComposeA11yLifecycle"
+ const val SCREEN_CHECK_INTERVAL_MILLIS = 500L
+ const val SETTLE_DELAY_MILLIS = 250L
+ const val REQUIRED_STABLE_SAMPLES = 4
+ const val MINIMUM_STABLE_DURATION_MILLIS = 750L
+
+ fun newStabilityTracker() = SemanticStabilityTracker(
+ requiredStableSamples = REQUIRED_STABLE_SAMPLES,
+ minimumStableDurationMillis = MINIMUM_STABLE_DURATION_MILLIS,
+ )
+ }
+}
+
+internal data class ScreenSnapshot(
+ val fingerprint: ScreenFingerprint,
+ val readiness: ReadinessFingerprint,
+)
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeHostFinder.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeHostFinder.kt
new file mode 100644
index 0000000..d2c2c2d
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeHostFinder.kt
@@ -0,0 +1,136 @@
+package com.composea11yscanner.ui
+
+import android.graphics.Rect as AndroidRect
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.compose.ui.semantics.SemanticsOwner
+import com.composea11yscanner.core.model.A11yNode
+
+/** Finds the application Compose host while excluding the scanner's own overlay. */
+internal class ComposeHostFinder {
+ fun findBest(
+ decorView: ViewGroup,
+ excludeView: View?,
+ logScores: Boolean = true,
+ ): ComposeHostCandidate? {
+ val candidates = mutableListOf()
+ decorView.collectCandidates(excludeView, depth = 0, candidates)
+ if (logScores) candidates.forEach { candidate ->
+ Log.d(
+ LOG_TAG,
+ "Candidate score: identity=${System.identityHashCode(candidate.view)}, " +
+ "visibleTextNodes=${candidate.visibleTextNodes}, " +
+ "visibleNodes=${candidate.visibleNodes}, depth=${candidate.depth}",
+ )
+ }
+ return candidates.maxWithOrNull(
+ compareBy { it.visibleTextNodes }
+ .thenBy { it.visibleNodes }
+ .thenBy { it.depth },
+ )
+ }
+
+ fun logHosts(decorView: ViewGroup, excludeView: View?) {
+ decorView.logHosts(excludeView)
+ }
+
+ fun semanticsOwner(view: AbstractComposeView): SemanticsOwner? {
+ val composeOwnerView = view.getChildAt(0) ?: return null
+ return runCatching {
+ composeOwnerView.javaClass.getMethod("getSemanticsOwner")
+ .invoke(composeOwnerView) as? SemanticsOwner
+ }.getOrNull()
+ }
+
+ private fun ViewGroup.collectCandidates(
+ excludeView: View?,
+ depth: Int,
+ candidates: MutableList,
+ ) {
+ for (index in 0 until childCount) {
+ val child = getChildAt(index)
+ if (child is AbstractComposeView && child !== excludeView && child.isViable()) {
+ child.toCandidate(depth + 1)?.let(candidates::add)
+ }
+ if (child is ViewGroup && child !== excludeView) {
+ child.collectCandidates(excludeView, depth + 1, candidates)
+ }
+ }
+ }
+
+ private fun AbstractComposeView.isViable(): Boolean =
+ visibility == View.VISIBLE && isShown && isAttachedToWindow && isLaidOut &&
+ alpha > 0f && width > 0 && height > 0
+
+ private fun AbstractComposeView.toCandidate(depth: Int): ComposeHostCandidate? {
+ val owner = semanticsOwner(this) ?: return null
+ val nodes = runCatching { A11yNodeExtractor().extract(owner) }.getOrNull() ?: return null
+ return ComposeHostCandidate(
+ view = this,
+ nodes = nodes,
+ depth = depth,
+ visibleSemanticNodes = nodes.filter { it.bounds.intersects(width, height) },
+ )
+ }
+
+ private fun ViewGroup.logHosts(excludeView: View?, path: String = javaClass.simpleName) {
+ for (index in 0 until childCount) {
+ val child = getChildAt(index)
+ val childPath = "$path/$index:${child.javaClass.simpleName}"
+ if (child is AbstractComposeView) {
+ Log.d(LOG_TAG, "Candidate path=$childPath, ${child.description(child === excludeView)}")
+ }
+ if (child is ViewGroup) child.logHosts(excludeView, childPath)
+ }
+ }
+
+ companion object {
+ const val LOG_TAG = "ComposeA11yHosts"
+ }
+}
+
+internal data class ComposeHostCandidate(
+ val view: AbstractComposeView,
+ val nodes: List,
+ val depth: Int,
+ val visibleSemanticNodes: List,
+) {
+ val visibleTextNodes: Int get() = visibleSemanticNodes.count { it.composableName == "Text" }
+ val visibleNodes: Int get() = visibleSemanticNodes.size
+
+ fun snapshot(destinationKey: String?): ScreenSnapshot = ScreenSnapshot(
+ fingerprint = calculateScreenFingerprint(
+ hostIdentity = System.identityHashCode(view),
+ nodes = nodes,
+ destinationKey = destinationKey,
+ ),
+ readiness = calculateReadinessFingerprint(
+ hostIdentity = System.identityHashCode(view),
+ visibleNodes = visibleSemanticNodes,
+ ),
+ )
+}
+
+private fun AbstractComposeView.description(isExcluded: Boolean): String {
+ val location = IntArray(2)
+ getLocationOnScreen(location)
+ val visibleRect = AndroidRect()
+ val hasVisibleRect = getGlobalVisibleRect(visibleRect)
+ return "identity=${System.identityHashCode(this)}, excludedOverlay=$isExcluded, " +
+ "visibility=${visibility.visibilityName()}, shown=$isShown, attached=$isAttachedToWindow, " +
+ "laidOut=$isLaidOut, alpha=$alpha, size=${width}x$height, position=($x,$y), " +
+ "translation=($translationX,$translationY), screen=(${location[0]},${location[1]}), " +
+ "hasVisibleRect=$hasVisibleRect, visibleRect=$visibleRect, childCount=$childCount"
+}
+
+private fun Int.visibilityName(): String = when (this) {
+ View.VISIBLE -> "VISIBLE"
+ View.INVISIBLE -> "INVISIBLE"
+ View.GONE -> "GONE"
+ else -> toString()
+}
+
+private fun com.composea11yscanner.core.model.Rect.intersects(width: Int, height: Int): Boolean =
+ !isEmpty() && right > 0 && bottom > 0 && left < width && top < height
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeNodeProvider.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeNodeProvider.kt
new file mode 100644
index 0000000..ccbeac7
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ComposeNodeProvider.kt
@@ -0,0 +1,77 @@
+package com.composea11yscanner.ui
+
+import android.os.Build
+import android.util.Log
+import android.view.ViewGroup
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.InternalComposeUiApi
+
+/** Provides merged TalkBack nodes and unmerged rendered-text nodes for their respective rules. */
+internal class ComposeNodeProvider(
+ private val activity: ComponentActivity,
+ private val overlayViewProvider: () -> androidx.compose.ui.platform.ComposeView?,
+ private val hostFinder: ComposeHostFinder,
+) {
+ suspend fun mergedNodes(): List = runCatching {
+ val decor = activity.window.decorView as? ViewGroup ?: return emptyList()
+ hostFinder.logHosts(decor, overlayViewProvider())
+ val host = hostFinder.findBest(decor, overlayViewProvider()) ?: return emptyList()
+ Log.d(
+ ComposeHostFinder.LOG_TAG,
+ "Selected host: identity=${System.identityHashCode(host.view)}, " +
+ "visibleTextNodes=${host.visibleTextNodes}, visibleNodes=${host.visibleNodes}, " +
+ "depth=${host.depth}",
+ )
+ host.nodes
+ }.onFailure {
+ Log.e(ComposeHostFinder.LOG_TAG, "Failed to extract Compose semantics", it)
+ }.getOrDefault(emptyList())
+
+ @OptIn(InternalComposeUiApi::class)
+ suspend fun contrastNodes(): List {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ Log.i(
+ ComposeHostFinder.LOG_TAG,
+ "Rendered text contrast requires API 26 or newer; semantic rules continue without it",
+ )
+ return emptyList()
+ }
+ val decor = activity.window.decorView as? ViewGroup ?: return emptyList()
+ val host = hostFinder.findBest(decor, overlayViewProvider(), logScores = false)
+ ?: return emptyList()
+ val owner = hostFinder.semanticsOwner(host.view) ?: return emptyList()
+ val nodes = A11yNodeExtractor().extract(owner.unmergedRootSemanticsNode)
+ val bitmap = runCatching { captureRenderedView(activity.window, host.view) }
+ .onFailure {
+ Log.w(ComposeHostFinder.LOG_TAG, "Rendered pixel capture failed; contrast unavailable", it)
+ }.getOrNull() ?: return emptyList()
+ return try {
+ runCatching { RenderedTextContrastAnalyzer(host.view).analyze(nodes, bitmap) }
+ .onFailure {
+ Log.w(ComposeHostFinder.LOG_TAG, "Rendered text contrast analysis failed", it)
+ }.getOrDefault(emptyList())
+ } finally {
+ bitmap.recycle()
+ }
+ }
+}
+
+internal class ScreenSnapshotProvider(
+ private val activity: ComponentActivity,
+ private val overlayViewProvider: () -> androidx.compose.ui.platform.ComposeView?,
+ private val destinationKeyProvider: (() -> String?)?,
+ private val hostFinder: ComposeHostFinder,
+) {
+ fun current(): ScreenSnapshot? {
+ val decor = activity.window.decorView as? ViewGroup ?: return null
+ val candidate = hostFinder.findBest(decor, overlayViewProvider(), logScores = false)
+ ?: return null
+ if (candidate.nodes.none { it.depth > 0 }) return null
+ val destinationKey = destinationKeyProvider?.let { provider ->
+ runCatching(provider).onFailure {
+ Log.w("ComposeA11yLifecycle", "Destination key provider failed", it)
+ }.getOrNull()
+ }
+ return candidate.snapshot(destinationKey)
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/InspectionModeToggle.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/InspectionModeToggle.kt
new file mode 100644
index 0000000..f5f32ad
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/InspectionModeToggle.kt
@@ -0,0 +1,48 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+
+/**
+ * Switches between issue inspection and host-app interaction without discarding the scan result.
+ *
+ * In inspection mode, issue highlights consume taps to open their details. In interaction mode,
+ * those hit targets are removed so taps reach the host application normally.
+ */
+@Composable
+internal fun InspectionModeToggle(
+ inspectionEnabled: Boolean,
+ onInspectionEnabledChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val actionLabel = if (inspectionEnabled) {
+ "Interact with app"
+ } else {
+ "Resume issue inspection"
+ }
+
+ FloatingActionButton(
+ onClick = { onInspectionEnabledChange(!inspectionEnabled) },
+ modifier = modifier,
+ ) {
+ Icon(
+ imageVector = if (inspectionEnabled) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = actionLabel,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun InspectionModeTogglePreview() {
+ InspectionModeToggle(
+ inspectionEnabled = true,
+ onInspectionEnabledChange = {},
+ )
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzer.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzer.kt
index cc53938..8fe3532 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzer.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzer.kt
@@ -1,18 +1,42 @@
package com.composea11yscanner.ui
import android.graphics.Bitmap
+import android.os.Build
+import android.graphics.Rect as AndroidRect
+import android.os.Handler
+import android.os.Looper
import android.util.Log
+import android.view.PixelCopy
import android.view.View
+import android.view.Window
+import androidx.annotation.RequiresApi
import androidx.core.view.drawToBitmap
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.Color
import com.composea11yscanner.core.model.Rect
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
import kotlin.math.max
+import kotlinx.coroutines.suspendCancellableCoroutine
+import androidx.core.graphics.createBitmap
/** Enriches semantic Text nodes with colors measured from one rendered view capture. */
class RenderedTextContrastAnalyzer(private val rootView: View) {
/** Returns unchanged nodes when the rendered colors cannot be measured confidently. */
+ @Deprecated(
+ message = "Use analyze(nodes, bitmap) with a hardware-compatible PixelCopy capture",
+ )
fun analyze(nodes: List): List {
+ val bitmap = rootView.drawToBitmap()
+ return try {
+ analyze(nodes, bitmap)
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ /** Enriches [nodes] using a rendered bitmap whose origin matches the semantics host. */
+ fun analyze(nodes: List, bitmap: Bitmap): List {
nodes.forEach { node ->
Log.d(
"TextContrast",
@@ -23,35 +47,116 @@ class RenderedTextContrastAnalyzer(private val rootView: View) {
)
}
- if (nodes.none { it.composableName == "Text" && it.isEnabled }) return nodes
- if (rootView.width <= 0 || rootView.height <= 0) return nodes
+ val measurableNodeIds = measurableTextNodeIds(nodes)
+ if (measurableNodeIds.isEmpty()) return nodes
+ if (bitmap.width <= 0 || bitmap.height <= 0) return nodes
- val bitmap = rootView.drawToBitmap()
- return try {
- nodes.map { node ->
- if (node.composableName != "Text" || !node.isEnabled) return@map node
- val colors = SolidBackgroundTextColorEstimator.estimate(bitmap, node.bounds)
- ?: return@map node
-
- Log.d(
- "TextContrast",
- "measured: id=${node.nodeId}, " +
- "bounds=${node.bounds}, " +
- "foreground=${colors.foreground}, " +
- "background=${colors.background}",
- )
-
- node.copy(
- textColor = colors.foreground,
- backgroundColors = listOf(colors.background),
- )
- }
- } finally {
- bitmap.recycle()
+ return nodes.map { node ->
+ if (node.nodeId !in measurableNodeIds) return@map node
+ val colors = SolidBackgroundTextColorEstimator.estimate(bitmap, node.bounds)
+ ?: return@map node
+
+ Log.d(
+ "TextContrast",
+ "measured: id=${node.nodeId}, " +
+ "bounds=${node.bounds}, " +
+ "foreground=${colors.foreground}, " +
+ "background=${colors.background}",
+ )
+
+ node.copy(
+ textColor = colors.foreground,
+ backgroundColors = listOf(colors.background),
+ )
}
}
}
+/** Copies the rendered Compose host from the hardware-backed application window. */
+@RequiresApi(Build.VERSION_CODES.O)
+suspend fun captureRenderedView(window: Window, view: View): Bitmap {
+ check(view.isAttachedToWindow && view.width > 0 && view.height > 0) {
+ "Cannot capture a detached or empty Compose host"
+ }
+
+ val location = IntArray(2)
+ view.getLocationInWindow(location)
+ val sourceRect = AndroidRect(
+ location[0],
+ location[1],
+ location[0] + view.width,
+ location[1] + view.height,
+ )
+ val bitmap = createBitmap(view.width, view.height)
+
+ return suspendCancellableCoroutine { continuation ->
+ continuation.invokeOnCancellation {
+ if (!bitmap.isRecycled) bitmap.recycle()
+ }
+ runCatching {
+ PixelCopy.request(
+ window,
+ sourceRect,
+ bitmap,
+ { result ->
+ if (!continuation.isActive) {
+ if (!bitmap.isRecycled) bitmap.recycle()
+ } else if (result == PixelCopy.SUCCESS) {
+ continuation.resume(bitmap)
+ } else {
+ if (!bitmap.isRecycled) bitmap.recycle()
+ continuation.resumeWithException(
+ IllegalStateException("PixelCopy failed with status $result"),
+ )
+ }
+ },
+ Handler(Looper.getMainLooper()),
+ )
+ }.onFailure { error ->
+ if (!bitmap.isRecycled) bitmap.recycle()
+ if (continuation.isActive) continuation.resumeWithException(error)
+ }
+ }
+}
+
+/**
+ * Selects the tightest enabled semantic Text nodes available for pixel measurement.
+ *
+ * Merging containers can inherit their descendants' Text semantics while retaining bounds for the
+ * entire control. Measuring such a container can mistake a selected pill, icon, or adjacent surface
+ * for the text foreground. When a Text node has a Text descendant, only the descendant is measured.
+ */
+internal fun measurableTextNodeIds(nodes: List): Set {
+ val nodesByParent = nodes
+ .mapNotNull { node -> node.parentNodeId?.let { parentId -> parentId to node } }
+ .groupBy(keySelector = { it.first }, valueTransform = { it.second })
+
+ fun A11yNode.hasEnabledTextDescendant(): Boolean {
+ val pending = ArrayDeque(nodesByParent[nodeId].orEmpty())
+ val visited = mutableSetOf()
+ while (pending.isNotEmpty()) {
+ val descendant = pending.removeFirst()
+ if (!visited.add(descendant.nodeId)) continue
+ if (descendant.isEnabled && descendant.composableName == "Text") return true
+ pending.addAll(nodesByParent[descendant.nodeId].orEmpty())
+ }
+ return false
+ }
+
+ return nodes
+ .asSequence()
+ .filter { it.isEnabled && it.composableName == "Text" }
+ .filterNot { it.hasEnabledTextDescendant() }
+ // A merged target can inherit descendant text while keeping the bounds of the whole
+ // control. Those bounds may contain images, icons, and container colors, so measure the
+ // unmerged Text descendants instead. If none are available, skipping is safer than using
+ // a surface or image color as the text foreground.
+ .filterNot {
+ it.hasExplicitContentDescription && !it.textLabel.isNullOrBlank()
+ }
+ .mapTo(mutableSetOf(), A11yNode::nodeId)
+}
+
/** A rendered foreground/background pair suitable for WCAG contrast calculation. */
data class RenderedTextColors(
val foreground: Color,
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerOverlayContent.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerOverlayContent.kt
new file mode 100644
index 0000000..300faad
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerOverlayContent.kt
@@ -0,0 +1,99 @@
+package com.composea11yscanner.ui
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.composea11yscanner.core.model.A11yIssue
+import com.composea11yscanner.core.model.ScannerConfig
+import com.composea11yscanner.core.model.ScannerState
+
+/** Overlay UI installed above an activity's Compose content. */
+@Composable
+internal fun ScannerOverlayContent(
+ controller: A11yScannerController,
+ config: ScannerConfig,
+) {
+ var scannerState by remember { mutableStateOf(ScannerState.Idle) }
+ var selectedIssues by remember { mutableStateOf(emptyList()) }
+ var inspectionEnabled by remember { mutableStateOf(true) }
+
+ DisposableEffect(Unit) { onDispose { controller.stopScan() } }
+
+ LaunchedEffect(Unit) {
+ controller.stateFlow.collect { state ->
+ scannerState = state
+ if (state !is ScannerState.Complete) {
+ selectedIssues = emptyList()
+ inspectionEnabled = true
+ }
+ }
+ }
+
+ LaunchedEffect(config) {
+ controller.configure(config)
+ if (!config.autoScan) controller.clearState()
+ }
+
+ val scanResult = (scannerState as? ScannerState.Complete)?.result
+ Box(modifier = Modifier.fillMaxSize()) {
+ A11yIssueOverlay(
+ scanResult = scanResult.takeIf { inspectionEnabled },
+ onIssuesSelected = { selectedIssues = it },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ AnimatedVisibility(
+ visible = scannerState !is ScannerState.Idle && inspectionEnabled,
+ enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
+ exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .statusBarsPadding()
+ .fillMaxWidth(),
+ ) {
+ ScanSummaryBar(state = scannerState, modifier = Modifier.fillMaxWidth())
+ }
+
+ IssueDetailPanel(
+ issues = selectedIssues.takeIf { inspectionEnabled }.orEmpty(),
+ onDismiss = { selectedIssues = emptyList() },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+
+ AnimatedVisibility(
+ visible = scanResult != null,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .navigationBarsPadding()
+ .padding(16.dp),
+ ) {
+ InspectionModeToggle(
+ inspectionEnabled = inspectionEnabled,
+ onInspectionEnabledChange = { enabled ->
+ inspectionEnabled = enabled
+ if (!enabled) selectedIssues = emptyList()
+ },
+ )
+ }
+ }
+}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScreenIdentity.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScreenIdentity.kt
index 3151a31..51a7622 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScreenIdentity.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScreenIdentity.kt
@@ -40,6 +40,22 @@ internal data class ReadinessFingerprint(
val visibleTextNodeCount: Int,
val visibleInteractiveNodeCount: Int,
val visibleFocusableNodeCount: Int,
+ val visibleNodeShapes: List,
+)
+
+/** Semantics that must stop changing before a scan can safely evaluate labels and geometry. */
+internal data class SemanticReadinessShape(
+ val depth: Int,
+ val composableName: String,
+ val bounds: com.composea11yscanner.core.model.Rect,
+ val effectiveTouchBounds: com.composea11yscanner.core.model.Rect?,
+ val contentDescription: String?,
+ val textLabel: String?,
+ val role: String?,
+ val isFocusable: Boolean,
+ val isTouchTarget: Boolean,
+ val isEnabled: Boolean,
+ val isMergedDescendant: Boolean,
)
/**
@@ -87,4 +103,39 @@ internal fun calculateReadinessFingerprint(
visibleTextNodeCount = visibleNodes.count { it.composableName == "Text" },
visibleInteractiveNodeCount = visibleNodes.count(A11yNode::isTouchTarget),
visibleFocusableNodeCount = visibleNodes.count(A11yNode::isFocusable),
+ visibleNodeShapes = visibleNodes
+ .map { node ->
+ SemanticReadinessShape(
+ depth = node.depth,
+ composableName = node.composableName,
+ bounds = node.bounds,
+ effectiveTouchBounds = node.effectiveTouchBounds,
+ contentDescription = node.contentDescription,
+ textLabel = node.textLabel,
+ role = node.role?.name,
+ isFocusable = node.isFocusable,
+ isTouchTarget = node.isTouchTarget,
+ isEnabled = node.isEnabled,
+ isMergedDescendant = node.isMergedDescendant,
+ )
+ }
+ .sortedWith(
+ compareBy { it.depth }
+ .thenBy { it.composableName }
+ .thenBy { it.role.orEmpty() }
+ .thenBy { it.contentDescription.orEmpty() }
+ .thenBy { it.textLabel.orEmpty() }
+ .thenBy { it.bounds.left }
+ .thenBy { it.bounds.top }
+ .thenBy { it.bounds.right }
+ .thenBy { it.bounds.bottom }
+ .thenBy { it.effectiveTouchBounds?.left ?: Int.MIN_VALUE }
+ .thenBy { it.effectiveTouchBounds?.top ?: Int.MIN_VALUE }
+ .thenBy { it.effectiveTouchBounds?.right ?: Int.MIN_VALUE }
+ .thenBy { it.effectiveTouchBounds?.bottom ?: Int.MIN_VALUE }
+ .thenBy { it.isFocusable }
+ .thenBy { it.isTouchTarget }
+ .thenBy { it.isEnabled }
+ .thenBy { it.isMergedDescendant },
+ ),
)
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/SemanticStabilityTracker.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/SemanticStabilityTracker.kt
new file mode 100644
index 0000000..add2080
--- /dev/null
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/SemanticStabilityTracker.kt
@@ -0,0 +1,34 @@
+package com.composea11yscanner.ui
+
+/**
+ * Requires a semantic readiness fingerprint to remain unchanged across both a minimum number of
+ * observations and a minimum amount of time. A single matching pair is not sufficient because
+ * asynchronous content and Compose animations can pause briefly between updates.
+ */
+internal class SemanticStabilityTracker(
+ private val requiredStableSamples: Int,
+ private val minimumStableDurationMillis: Long,
+) {
+ private var lastReadiness: ReadinessFingerprint? = null
+ private var stableSampleCount = 0
+ private var stableSinceMillis = 0L
+
+ fun observe(readiness: ReadinessFingerprint, nowMillis: Long): Boolean {
+ if (readiness != lastReadiness) {
+ lastReadiness = readiness
+ stableSampleCount = 1
+ stableSinceMillis = nowMillis
+ return false
+ }
+
+ stableSampleCount++
+ return stableSampleCount >= requiredStableSamples &&
+ nowMillis - stableSinceMillis >= minimumStableDurationMillis
+ }
+
+ fun reset() {
+ lastReadiness = null
+ stableSampleCount = 0
+ stableSinceMillis = 0L
+ }
+}
diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ComposeA11yScannerIntegrationTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ComposeA11yScannerIntegrationTest.kt
index 31f5fd9..9f205ed 100644
--- a/scanner-ui/src/test/java/com/composea11yscanner/ComposeA11yScannerIntegrationTest.kt
+++ b/scanner-ui/src/test/java/com/composea11yscanner/ComposeA11yScannerIntegrationTest.kt
@@ -181,6 +181,44 @@ class ComposeA11yScannerIntegrationTest {
assertNull(ComposeA11yScanner.triggerIfEnabled().firstOrNull())
}
+ @Test
+ fun `automatic resume reuses manual coordinator and stops routing after pause`() {
+ val activity = activity()
+ ComposeA11yScanner.resetForTests()
+ ComposeA11yScanner.install(activity, config)
+ val controller = ComposeA11yScanner.controllerForTests(activity)
+ val overlay = ComposeA11yScanner.overlayForTests(activity)
+
+ ComposeA11yScanner.resume(activity, config)
+
+ assertSame(controller, ComposeA11yScanner.controllerForTests(activity))
+ assertSame(overlay, ComposeA11yScanner.overlayForTests(activity))
+ assertSame(activity, ComposeA11yScanner.activeActivityForTests())
+
+ ComposeA11yScanner.pause(activity)
+
+ assertNull(ComposeA11yScanner.activeActivityForTests())
+ assertSame(controller, ComposeA11yScanner.controllerForTests(activity))
+ }
+
+ @Test
+ fun `new manual installation does not steal scans from resumed automatic activity`() {
+ val automatic = activity()
+ val manual = activity()
+ ComposeA11yScanner.resetForTests()
+ ComposeA11yScanner.resume(automatic, config)
+ ComposeA11yScanner.install(manual, config)
+
+ assertSame(automatic, ComposeA11yScanner.activeActivityForTests())
+ ComposeA11yScanner.triggerScan()
+ assertTrue(ComposeA11yScanner.controllerForTests(automatic)?.currentState is ScannerState.Scanning)
+ assertTrue(ComposeA11yScanner.controllerForTests(manual)?.currentState is ScannerState.Idle)
+
+ ComposeA11yScanner.pause(automatic)
+ ComposeA11yScanner.triggerScan()
+ assertTrue(ComposeA11yScanner.controllerForTests(manual)?.currentState is ScannerState.Scanning)
+ }
+
private fun activity(): TestActivity =
Robolectric.buildActivity(TestActivity::class.java).create().start().get()
diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzerTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzerTest.kt
new file mode 100644
index 0000000..3410642
--- /dev/null
+++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/RenderedTextContrastAnalyzerTest.kt
@@ -0,0 +1,84 @@
+package com.composea11yscanner.ui
+
+import com.composea11yscanner.core.model.Rect
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RenderedTextContrastAnalyzerTest {
+
+ @Test
+ fun `merged text container is skipped when tighter text descendant exists`() {
+ val navigationItem = nodeFixture().copy(
+ nodeId = "home-item",
+ composableName = "Text",
+ bounds = Rect(76, 2049, 349, 2181),
+ parentNodeId = "navigation-bar",
+ )
+ val homeLabel = nodeFixture().copy(
+ nodeId = "home-label",
+ composableName = "Text",
+ bounds = Rect(177, 2084, 283, 2130),
+ parentNodeId = navigationItem.nodeId,
+ isTouchTarget = false,
+ isFocusable = false,
+ isMergedDescendant = true,
+ )
+
+ assertEquals(
+ setOf(homeLabel.nodeId),
+ measurableTextNodeIds(listOf(navigationItem, homeLabel)),
+ )
+ }
+
+ @Test
+ fun `standalone clickable text remains measurable`() {
+ val clickableText = nodeFixture().copy(
+ nodeId = "terms-link",
+ composableName = "Text",
+ isTouchTarget = true,
+ )
+
+ assertEquals(
+ setOf(clickableText.nodeId),
+ measurableTextNodeIds(listOf(clickableText)),
+ )
+ }
+
+ @Test
+ fun `disabled text descendants do not suppress enabled parent measurement`() {
+ val parent = nodeFixture().copy(
+ nodeId = "parent",
+ composableName = "Text",
+ )
+ val disabledChild = nodeFixture().copy(
+ nodeId = "disabled-child",
+ composableName = "Text",
+ parentNodeId = parent.nodeId,
+ isEnabled = false,
+ )
+
+ assertEquals(
+ setOf(parent.nodeId),
+ measurableTextNodeIds(listOf(parent, disabledChild)),
+ )
+ }
+
+ @Test
+ fun `merged described control is skipped when leaf text bounds are unavailable`() {
+ val mergedBookCard = nodeFixture().copy(
+ nodeId = "book-card",
+ composableName = "Text",
+ contentDescription = "Book cover image",
+ hasExplicitContentDescription = true,
+ textLabel = "Moby Dick Herman Melville English",
+ isTouchTarget = true,
+ isFocusable = true,
+ bounds = Rect(33, 283, 1047, 779),
+ )
+
+ assertEquals(
+ emptySet(),
+ measurableTextNodeIds(listOf(mergedBookCard)),
+ )
+ }
+}
diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/ScreenIdentityTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/ScreenIdentityTest.kt
index 0cfefc0..a49628e 100644
--- a/scanner-ui/src/test/java/com/composea11yscanner/ui/ScreenIdentityTest.kt
+++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/ScreenIdentityTest.kt
@@ -77,8 +77,9 @@ class ScreenIdentityTest {
}
@Test
- fun `readiness ignores ids and exact bounds but observes visible node counts`() {
+ fun `readiness ignores ids but observes bounds and visible node counts`() {
val first = listOf(node("one", depth = 1), node("two", depth = 2, name = "Text"))
+ val recreated = listOf(node("nine", depth = 1), node("ten", depth = 2, name = "Text"))
val animated = listOf(
node("nine", depth = 1, bounds = Rect(10, 10, 90, 90)),
node("ten", depth = 2, name = "Text", bounds = Rect(20, 20, 80, 80)),
@@ -87,6 +88,10 @@ class ScreenIdentityTest {
assertEquals(
calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = first),
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = recreated),
+ )
+ assertNotEquals(
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = recreated),
calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = animated),
)
assertNotEquals(
@@ -95,22 +100,80 @@ class ScreenIdentityTest {
)
}
+ @Test
+ fun `readiness observes semantic text becoming available`() {
+ val incomplete = listOf(
+ node(
+ "book-1",
+ depth = 4,
+ name = "ClickableText",
+ contentDescription = "Book cover image",
+ ),
+ )
+ val complete = listOf(
+ node(
+ "book-9",
+ depth = 4,
+ name = "ClickableText",
+ contentDescription = "Book cover image",
+ textLabel = "Moby Dick, Herman Melville, English",
+ ),
+ )
+
+ assertNotEquals(
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = incomplete),
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = complete),
+ )
+ }
+
+ @Test
+ fun `readiness observes effective touch bounds settling after navigation`() {
+ val transitioning = listOf(
+ node(
+ "book-old",
+ depth = 4,
+ name = "ClickableText",
+ isTouchTarget = true,
+ effectiveTouchBounds = Rect(0, 0, 100, 120),
+ ),
+ )
+ val settled = listOf(
+ node(
+ "book-new",
+ depth = 4,
+ name = "ClickableText",
+ isTouchTarget = true,
+ effectiveTouchBounds = Rect(0, 0, 100, 100),
+ ),
+ )
+
+ assertNotEquals(
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = transitioning),
+ calculateReadinessFingerprint(hostIdentity = 1, visibleNodes = settled),
+ )
+ }
+
private fun node(
id: String,
depth: Int,
name: String = "Unknown",
bounds: Rect = Rect(0, 0, 100, 100),
isTouchTarget: Boolean = false,
+ effectiveTouchBounds: Rect? = bounds.takeIf { isTouchTarget },
+ contentDescription: String? = null,
+ textLabel: String? = null,
): A11yNode = A11yNode(
nodeId = id,
composableName = name,
bounds = bounds,
- contentDescription = null,
+ contentDescription = contentDescription,
isTouchTarget = isTouchTarget,
textColor = null,
backgroundColors = emptyList(),
isFocusable = isTouchTarget,
isMergedDescendant = false,
depth = depth,
+ effectiveTouchBounds = effectiveTouchBounds,
+ textLabel = textLabel,
)
}
diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/SemanticStabilityTrackerTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/SemanticStabilityTrackerTest.kt
new file mode 100644
index 0000000..f6cfe8f
--- /dev/null
+++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/SemanticStabilityTrackerTest.kt
@@ -0,0 +1,61 @@
+package com.composea11yscanner.ui
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SemanticStabilityTrackerTest {
+
+ @Test
+ fun `requires consecutive samples and minimum stable duration`() {
+ val tracker = SemanticStabilityTracker(
+ requiredStableSamples = 4,
+ minimumStableDurationMillis = 750L,
+ )
+ val readiness = readiness(visibleNodeCount = 10)
+
+ assertFalse(tracker.observe(readiness, nowMillis = 0L))
+ assertFalse(tracker.observe(readiness, nowMillis = 250L))
+ assertFalse(tracker.observe(readiness, nowMillis = 500L))
+ assertTrue(tracker.observe(readiness, nowMillis = 750L))
+ }
+
+ @Test
+ fun `semantic change restarts the stable window`() {
+ val tracker = SemanticStabilityTracker(
+ requiredStableSamples = 3,
+ minimumStableDurationMillis = 500L,
+ )
+ val loading = readiness(visibleNodeCount = 2)
+ val content = readiness(visibleNodeCount = 10)
+
+ assertFalse(tracker.observe(loading, nowMillis = 0L))
+ assertFalse(tracker.observe(loading, nowMillis = 250L))
+ assertFalse(tracker.observe(content, nowMillis = 500L))
+ assertFalse(tracker.observe(content, nowMillis = 750L))
+ assertTrue(tracker.observe(content, nowMillis = 1_000L))
+ }
+
+ @Test
+ fun `reset discards previously stable observations`() {
+ val tracker = SemanticStabilityTracker(
+ requiredStableSamples = 2,
+ minimumStableDurationMillis = 100L,
+ )
+ val readiness = readiness(visibleNodeCount = 10)
+
+ assertFalse(tracker.observe(readiness, nowMillis = 0L))
+ assertTrue(tracker.observe(readiness, nowMillis = 100L))
+ tracker.reset()
+ assertFalse(tracker.observe(readiness, nowMillis = 200L))
+ }
+
+ private fun readiness(visibleNodeCount: Int): ReadinessFingerprint = ReadinessFingerprint(
+ hostIdentity = 1,
+ visibleNodeCount = visibleNodeCount,
+ visibleTextNodeCount = visibleNodeCount,
+ visibleInteractiveNodeCount = 0,
+ visibleFocusableNodeCount = 0,
+ visibleNodeShapes = emptyList(),
+ )
+}