From 63d5b14777525c4c8d4dc1d8cd88a4d838135569 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 14 Jul 2026 09:10:18 +0530 Subject: [PATCH] Improve touch target detection and UI layout in the A11y Scanner - Update `A11yNodeExtractor` to use `touchBoundsInRoot` instead of layout bounds, ensuring that compact components with expanded touch regions (like small chips) are correctly evaluated. - Introduce `MeasurementToleranceDp` (0.01dp) in `TouchTargetRule` to prevent false negatives caused by floating-point rounding errors during coordinate conversion. - Add `windowInsetsPadding` to `ScanSummaryBar` to prevent the summary UI from overlapping with system status bars. - Add unit and instrumentation tests to verify measurement tolerance and expanded touch bound extraction for compact clickable elements. --- .../rules/TouchTargetRule.kt | 11 +++++++- .../rules/TouchTargetRuleTest.kt | 24 ++++++++++++++++++ .../ui/A11yNodeExtractorTest.kt | 25 +++++++++++++++++++ .../ui/A11yNodeExtractor.kt | 15 +++-------- .../composea11yscanner/ui/ScanSummaryBar.kt | 5 ++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt index aba89d9..f9c7df4 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt @@ -19,7 +19,12 @@ class TouchTargetRule( val w = node.touchTargetSize.width val h = node.touchTargetSize.height - if (w >= minTouchTargetDp && h >= minTouchTargetDp) return null + // Pixel bounds converted back to dp can land infinitesimally below an exact dp value + // (for example, a 48 dp target may be reported as 47.999996 dp). + if ( + w + MeasurementToleranceDp >= minTouchTargetDp && + h + MeasurementToleranceDp >= minTouchTargetDp + ) return null return issue( node = node, @@ -29,4 +34,8 @@ class TouchTargetRule( "composable reaches at least ${minTouchTargetDp}dp in both dimensions.", ) } + + private companion object { + const val MeasurementToleranceDp = 0.01f + } } diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt index ecd0ec9..bde8244 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt @@ -24,6 +24,18 @@ class TouchTargetRuleTest { assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f)))) } + @Test + fun `floating point noise just below minimum size passes`() { + assertNull( + rule.evaluate( + createNode( + isTouchTarget = true, + touchTargetSize = DpSize(47.999996f, 47.999996f), + ) + ) + ) + } + @Test fun `touch target larger than minimum passes`() { assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(56f, 64f)))) @@ -49,6 +61,18 @@ class TouchTargetRuleTest { assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(44f, 48f)))) } + @Test + fun `meaningfully undersized target is not hidden by measurement tolerance`() { + assertNotNull( + rule.evaluate( + createNode( + isTouchTarget = true, + touchTargetSize = DpSize(47.98f, 48f), + ) + ) + ) + } + @Test fun `height below minimum fails`() { assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 44f)))) 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 63083a0..452c7bf 100644 --- a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt +++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt @@ -2,11 +2,14 @@ package com.composea11yscanner.ui import androidx.compose.foundation.clickable 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.platform.LocalDensity import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -49,4 +52,26 @@ class A11yNodeExtractorTest { assertEquals("Clickable", clickableNode.composableName) assertTrue(clickableNode.contentDescription.isNullOrBlank()) } + + @Test + fun compactClickable_usesExpandedTouchBounds() { + lateinit var density: Density + composeRule.setContent { + density = LocalDensity.current + Box( + modifier = Modifier + .size(width = 40.dp, height = 48.dp) + .clickable { }, + ) + } + + composeRule.waitForIdle() + + val nodes = A11yNodeExtractor(density) + .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) + val clickableNode = nodes.single { it.isTouchTarget } + + assertTrue(clickableNode.touchTargetSize.width >= 48f) + assertTrue(clickableNode.touchTargetSize.height >= 48f) + } } 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 1dd294d..3363d37 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt @@ -68,7 +68,10 @@ class A11yNodeExtractor(private val density: Density) { null } val visualBounds = boundsInRoot - val touchTargetBounds = layoutBoundsInRoot() + // Compose expands pointer input for small clickables to the platform minimum touch + // target without changing their visual/layout bounds. Measure the region that actually + // accepts input so compact controls such as a short FilterChip are not false positives. + val touchTargetBounds = touchBoundsInRoot val bounds = visualBounds.toCoreRect() return A11yNode( @@ -146,16 +149,6 @@ class A11yNodeExtractor(private val density: Density) { } } - private fun SemanticsNode.layoutBoundsInRoot(): androidx.compose.ui.geometry.Rect { - val position = positionInRoot - return androidx.compose.ui.geometry.Rect( - left = position.x, - top = position.y, - right = position.x + layoutInfo.width, - bottom = position.y + layoutInfo.height, - ) - } - private fun androidx.compose.ui.geometry.Rect.toCoreRect(): Rect = Rect( left = left.roundToInt(), top = top.roundToInt(), diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt index b99f15f..4d3c101 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt @@ -18,9 +18,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -101,6 +104,8 @@ fun ScanSummaryBar( Box( modifier = modifier .fillMaxWidth() + // topOffset clears the host toolbar; the inset additionally clears the system bar. + .windowInsetsPadding(WindowInsets.statusBars) .padding(top = topOffset, start = 16.dp, end = 16.dp), contentAlignment = Alignment.TopCenter, ) {