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(), + ) +}