diff --git a/.gitignore b/.gitignore index b904214..0e9511a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,14 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store -/build +build/ /captures .externalNativeBuild .cxx local.properties /.idea/markdown.xml /.idea/gradle.xml -/.idea/misc.xml \ No newline at end of file +/.idea/misc.xml +.claude/ +.idea/ +.kotlin/errors/ \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 11a0f92..1395e19 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,6 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) } @@ -41,10 +39,14 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { implementation(project(":scanner-ui")) - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt b/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt index 1fd4188..9c272a9 100644 --- a/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt +++ b/app/src/main/java/com/composea11yscanner/ComposeA11yScannerApplication.kt @@ -1,7 +1,5 @@ package com.composea11yscanner import android.app.Application -import dagger.hilt.android.HiltAndroidApp -@HiltAndroidApp class ComposeA11yScannerApplication : Application() diff --git a/build.gradle.kts b/build.gradle.kts index 51cd901..36d69e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,8 +4,6 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.hilt) apply false - alias(libs.plugins.ksp) apply false alias(libs.plugins.detekt) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f29b7e..af9f524 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,8 +9,6 @@ activityCompose = "1.9.3" appStartup = "1.2.0" kotlin = "2.3.21" composeBom = "2024.12.01" -hilt = "2.54" -ksp = "2.1.0-1.0.29" coroutines = "1.10.1" mockk = "1.13.12" turbine = "1.2.0" @@ -38,8 +36,6 @@ androidx-compose-animation = { group = "androidx.compose.animation", name = "ani androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } -hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } @@ -52,7 +48,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index b5e1330..43930f0 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,8 +1,6 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) } @@ -33,6 +31,12 @@ android { buildFeatures { compose = true } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { implementation(project(":scanner-ui")) implementation(project(":scanner-core")) @@ -45,8 +49,6 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt new file mode 100644 index 0000000..3876358 --- /dev/null +++ b/sample/src/androidTest/java/com/composea11yscanner/sample/FixedScreenAccessibilityTest.kt @@ -0,0 +1,63 @@ +package com.composea11yscanner.sample + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import com.composea11yscanner.core.model.ScannerConfig +import com.composea11yscanner.rules.ScannerRules +import com.composea11yscanner.ui.A11yScanner +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class FixedScreenAccessibilityTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun fixedLoginScreen_hasNoScannerIssues() { + assertNoScannerIssues { + FixedLoginScreen() + } + } + + @Test + fun fixedFeedScreen_hasNoScannerIssues() { + assertNoScannerIssues { + FixedFeedScreen() + } + } + + @Test + fun fixedFormScreen_hasNoScannerIssues() { + assertNoScannerIssues { + FixedFormScreen() + } + } + + private fun assertNoScannerIssues(content: @Composable () -> Unit) { + composeRule.setContent { + MaterialTheme { + content() + } + } + + composeRule.waitForIdle() + + val config = ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet()) + val scanner = A11yScanner( + rules = ScannerRules.buildRules(config, screenDensity = 1f), + density = Density(1f), + ) + val result = scanner.scan(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) + + assertEquals( + "Expected fixed screen to have zero scanner issues. Issues: ${result.issues}", + 0, + result.issues.size, + ) + } +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 017cbce..757cbe8 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -20,6 +20,15 @@ android:name="a11y_scanner_auto_scan" android:value="false" /> + + + + - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Scan Trigger Demo", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) + LaunchedEffect(selectedScreen, viewingFixed) { + scanScrollY = scrollState.value + scannerController.clearState() + } + + scanOnShake(onScanRequested = { startSampleScan() }) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val useWideLayout = maxWidth >= 720.dp - if (useWideLayout) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + A11yScannerScaffold( + scannerController = scannerController, + config = scannerConfig, + modifier = modifier.fillMaxSize(), + issueOffsetY = issueOffsetY, + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "Compose A11y Scanner", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + }, + actions = { + IconButton(onClick = { scannerController.clearState() }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear scan results", + ) + } + IconButton(onClick = { startSampleScan() }) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Scan selected sample", + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + bottomBar = { + NavigationBar { + screens.forEachIndexed { index, label -> + NavigationBarItem( + selected = selectedScreen == index, + onClick = { + selectedScreen = index + viewingFixed = false + }, + icon = { + Icon( + imageVector = if (selectedScreen == index) { + Icons.Filled.CheckCircle + } else { + Icons.Filled.Search + }, + contentDescription = null, + ) + }, + label = { Text(label) }, + ) + } + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .verticalScroll(scrollState) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - TriggerOptionCard( - title = "Manual", - body = "Button tap", - modifier = Modifier.weight(1f), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Button( - onClick = { ComposeA11yScanner.triggerScan() }, - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Text("Run Scan") + Text( + text = screens[selectedScreen], + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = screenSubtitles[selectedScreen], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - TriggerOptionCard( - title = "Long Press", - body = "Hold this panel", - modifier = Modifier - .weight(1f) - .scanOnLongPress(), + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - TriggerTarget("Hold") + FilterChip( + selected = !viewingFixed, + onClick = { viewingFixed = false }, + label = { Text("Broken") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) + FilterChip( + selected = viewingFixed, + onClick = { viewingFixed = true }, + label = { Text("Fixed") }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + ) } - ShakeTriggerCard( - enabled = shakeEnabled, - onEnabledChange = { shakeEnabled = it }, - modifier = Modifier.weight(1f), - ) } - } else { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - TriggerOptionCard(title = "Manual", body = "Button tap") { - Button(onClick = { ComposeA11yScanner.triggerScan() }) { - Text("Run Scan") + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Box( + modifier = Modifier + .padding(8.dp) + .semantics { testTag = BrokenSampleContentTag }, + ) + { + if (viewingFixed) { + when (selectedScreen) { + 0 -> FixedLoginScreen() + 1 -> FixedFeedScreen() + else -> FixedFormScreen() + } + } else { + when (selectedScreen) { + 0 -> BrokenLoginScreen() + 1 -> BrokenFeedScreen() + else -> BrokenFormScreen() } } - TriggerOptionCard( - title = "Long Press", - body = "Hold this panel", - modifier = Modifier.scanOnLongPress(), - ) { - TriggerTarget("Hold") - } - ShakeTriggerCard( - enabled = shakeEnabled, - onEnabledChange = { shakeEnabled = it }, - ) } } } + } + } +} + +@Composable +private fun BrokenLoginScreen(onViewFixed: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Screen 1: Broken Login", + subtitle = "Missing content descriptions and small touch targets", + ) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + TinyClickableLabel("?", Color(0xFF006D77)) + TinyClickableLabel("!", Color(0xFFE29578)) + TinyClickableLabel("x", Color(0xFF8D99AE)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF2A9D8F)) + .clickable { }, + contentAlignment = Alignment.Center, + ) { + Text("Go", color = Color.White) + } + Text( + text = "Forgot password", + modifier = Modifier + .size(width = 92.dp, height = 30.dp) + .clickable { }, + color = Color(0xFF1565C0), + ) + } + onViewFixed?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Fixed Version") + } + } + } +} - DemoScanContent() +@Composable +private fun BrokenFeedScreen(onViewFixed: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Screen 2: Broken Feed", + subtitle = "Poor contrast text over images and duplicate descriptions", + ) { + FeedHero( + imageColor = Color(0xFF9E9E9E), + title = "Morning trail update", + description = "Open story", + ) + FeedHero( + imageColor = Color(0xFF78909C), + title = "City lights tonight", + description = "Open story", + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DuplicateAction("Save", "Save item") + DuplicateAction("Bookmark", "Save item") + } + onViewFixed?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Fixed Version") + } } } } @Composable -private fun TriggerOptionCard( - title: String, - body: String, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Card( - modifier = modifier.height(164.dp), - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), +private fun BrokenFormScreen(onViewFixed: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Screen 3: Broken Form", + subtitle = "Clickable elements lack roles and source order breaks focus flow", ) { - Column( + Text("Preference form", style = MaterialTheme.typography.titleMedium) + + Box( modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth() + .height(180.dp) + .background(Color(0xFFECEFF1), RoundedCornerShape(8.dp)), ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text(body, style = MaterialTheme.typography.bodyMedium) + FormAction( + text = "Submit preferences", + color = Color(0xFF00796B), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + ) + FormAction( + text = "Choose plan", + color = Color(0xFF5E35B1), + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) + FormAction( + text = "Reset fields", + color = Color(0xFFC62828), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(16.dp), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FormAction("Monthly", Color(0xFF455A64), Modifier.weight(1f)) + FormAction("Yearly", Color(0xFF455A64), Modifier.weight(1f)) + } + onViewFixed?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Fixed Version") } - content() } } } @Composable -private fun ShakeTriggerCard( - enabled: Boolean, - onEnabledChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - TriggerOptionCard( - title = "Shake", - body = if (enabled) "Sensor armed" else "Sensor off", - modifier = modifier, +fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Fixed Login", + subtitle = "Described controls with touch targets at least 48dp", ) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FixedIconAction("?", "Open login help", Color(0xFF006D77)) + FixedIconAction("!", "Show login requirements", Color(0xFFE29578)) + FixedIconAction("x", "Dismiss login form", Color(0xFF8D99AE)) + } + Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Enabled", style = MaterialTheme.typography.labelLarge) - Switch(checked = enabled, onCheckedChange = onEnabledChange) + Box( + modifier = Modifier + .size(width = 56.dp, height = 48.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF2A9D8F)) + .semantics { contentDescription = "Sign in" } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text("Go", color = Color.White) + } + Box( + modifier = Modifier + .size(width = 144.dp, height = 48.dp) + .semantics { contentDescription = "Reset password" } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text("Forgot password", color = Color(0xFF1565C0)) + } + } + onViewBroken?.let { + Button( + onClick = it, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + Text("View Broken Version") + } } } } @Composable -private fun TriggerTarget(label: String) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) - .padding(horizontal = 16.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, +fun FixedFeedScreen(onViewBroken: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Fixed Feed", + subtitle = "Readable captions and unique descriptions", ) { - Text(label, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.SemiBold) + FixedFeedItem( + imageColor = Color(0xFF607D8B), + title = "Morning trail update", + description = "Open morning trail story", + ) + FixedFeedItem( + imageColor = Color(0xFF546E7A), + title = "City lights tonight", + description = "Open city lights story", + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FixedLabeledAction("Save", "Save feed item") + FixedLabeledAction("Bookmark", "Bookmark feed item") + } + onViewBroken?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Broken Version") + } + } } } @Composable -private fun DemoScanContent() { +fun FixedFormScreen(onViewBroken: (() -> Unit)? = null) { + BrokenScreenCard( + title = "Fixed Form", + subtitle = "Button roles and top-to-bottom focus order", + ) { + Text("Preference form", style = MaterialTheme.typography.titleMedium) + Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("Choose plan") + } + Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("Reset fields") + } + Button(onClick = {}, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("Submit preferences") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { + Text("Monthly") + } + Button(onClick = {}, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { + Text("Yearly") + } + } + onViewBroken?.let { + Button(onClick = it, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("View Broken Version") + } + } + } +} + +@Composable +private fun BrokenScreenCard( + title: String, + subtitle: String, + content: @Composable ColumnScope.() -> Unit, +) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), @@ -211,33 +587,205 @@ private fun DemoScanContent() { ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text("Inspectable Content", style = MaterialTheme.typography.titleMedium) - Text("These controls intentionally include a few accessibility issues for the scanner.") - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(28.dp) - .background(Color(0xFF1976D2), RoundedCornerShape(4.dp)) - .clickable { }, - ) - Spacer(modifier = Modifier.width(12.dp)) - Text("Small clickable swatch") + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) } - Text( - text = "Low contrast text sample", - color = Color(0xFFBDBDBD), - modifier = Modifier.background(Color.White), - ) + content() } } } +@Composable +private fun TinyClickableLabel(text: String, color: Color) { + Box( + modifier = Modifier + .size(28.dp) + .background(color, RoundedCornerShape(6.dp)) + .clickable { }, + contentAlignment = Alignment.Center, + ) { + Text(text, color = Color.White, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun FeedHero( + imageColor: Color, + title: String, + description: String, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .clip(RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable { }, + ) { + Image( + painter = ColorPainter(imageColor), + contentDescription = description, + modifier = Modifier.fillMaxSize(), + ) + Text( + text = title, + color = Color(0xFFBDBDBD), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(14.dp), + ) + } +} + +@Composable +private fun DuplicateAction(label: String, description: String) { + Box( + modifier = Modifier + .size(width = 132.dp, height = 52.dp) + .background(Color(0xFF283593), RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable { }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = Color.White) + } +} + +@Composable +private fun FormAction( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .height(56.dp) + .background(color, RoundedCornerShape(8.dp)) + .clickable { } + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text(text, color = Color.White, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun FixedIconAction( + label: String, + description: String, + color: Color, +) { + Box( + modifier = Modifier + .size(56.dp) + .background(color, RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text(label, color = Color.White, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun FixedFeedItem( + imageColor: Color, + title: String, + description: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = description } + .clickable( + role = Role.Button, + onClick = {}, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + painter = ColorPainter(imageColor), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(8.dp)), + ) + Text(title, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun FixedLabeledAction(label: String, description: String) { + Box( + modifier = Modifier + .size(width = 132.dp, height = 56.dp) + .background(Color(0xFF283593), RoundedCornerShape(8.dp)) + .semantics { contentDescription = description } + .clickable( + role = Role.Button, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Text(label, color = Color.White) + } +} + @Preview(showBackground = true) @Composable -fun ScanTriggerDemoPreview() { +fun BrokenAccessibilitySampleAppPreview() { MaterialTheme { - ScanTriggerDemo() + BrokenAccessibilitySampleApp() } } + +private fun ComponentActivity.extractBrokenSampleNodes(): List = + runCatching { + val hostView = (window.decorView as? ViewGroup) + ?.findFirstAbstractComposeView() + ?: return emptyList() + val semanticsOwner = hostView.findSemanticsOwner() ?: return emptyList() + val sampleRoot = semanticsOwner.unmergedRootSemanticsNode + .findNodeByTestTag(BrokenSampleContentTag) + ?: return emptyList() + A11yNodeExtractor(Density(this)).extract(sampleRoot) + }.getOrDefault(emptyList()) + +private const val BrokenSampleContentTag = "broken-sample-content" + +private fun SemanticsNode.findNodeByTestTag(tag: String): SemanticsNode? { + if (config.getOrNull(SemanticsProperties.TestTag) == tag) return this + children.forEach { child -> + child.findNodeByTestTag(tag)?.let { return it } + } + return null +} + +private fun ViewGroup.findFirstAbstractComposeView(): AbstractComposeView? { + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child is AbstractComposeView) return child + if (child is ViewGroup) { + child.findFirstAbstractComposeView()?.let { return it } + } + } + return null +} + +private fun AbstractComposeView.findSemanticsOwner(): SemanticsOwner? { + val composeOwnerView: View = getChildAt(0) ?: return null + return runCatching { + composeOwnerView.javaClass + .getMethod("getSemanticsOwner") + .invoke(composeOwnerView) as? SemanticsOwner + }.getOrNull() +} diff --git a/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt b/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt index d8f21dc..786c9d0 100644 --- a/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt +++ b/sample/src/main/java/com/composea11yscanner/sample/SampleApplication.kt @@ -1,7 +1,5 @@ package com.composea11yscanner.sample import android.app.Application -import dagger.hilt.android.HiltAndroidApp -@HiltAndroidApp class SampleApplication : Application() diff --git a/scanner-core/build.gradle.kts b/scanner-core/build.gradle.kts index c87c248..3548419 100644 --- a/scanner-core/build.gradle.kts +++ b/scanner-core/build.gradle.kts @@ -1,7 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) } @@ -30,10 +28,14 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { implementation(libs.androidx.core.ktx) - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) implementation(platform(libs.androidx.compose.bom)) diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt index 0fbb925..45c6221 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt @@ -12,4 +12,5 @@ enum class A11yRole { RadioButton, Switch, Tab, + TextField, } diff --git a/scanner-rules/build.gradle.kts b/scanner-rules/build.gradle.kts index 746373c..b5e1a72 100644 --- a/scanner-rules/build.gradle.kts +++ b/scanner-rules/build.gradle.kts @@ -1,7 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) } @@ -30,11 +28,15 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { implementation(project(":scanner-core")) implementation(libs.androidx.core.ktx) - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt index a5e8ec0..d5b764b 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt @@ -14,6 +14,7 @@ class ClickableRoleRule : BaseA11yRule() { override val wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)" override fun check(node: A11yNode): A11yIssue? { + if (node.isMergedDescendant) return null if (!node.isTouchTarget) return null val missingRole = node.role == null 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 e932848..729dd96 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt @@ -24,8 +24,7 @@ class FocusOrderRule( private val jumpThresholdPx: Int = (jumpThresholdDp * screenDensity).roundToInt() override fun evaluateAll(nodes: List): List = - nodes - .filter { it.isFocusable } + effectiveFocusNodes(nodes) .zipWithNext { prev, curr -> val jumpedUpward = curr.bounds.top < prev.bounds.top - jumpThresholdPx if (!jumpedUpward) return@zipWithNext null @@ -43,4 +42,25 @@ class FocusOrderRule( ) } .filterNotNull() + + private fun effectiveFocusNodes(nodes: List): List { + val result = mutableListOf() + + nodes + .filter { it.isFocusable && !it.isMergedDescendant } + .forEach { node -> + val hasFocusableAncestor = result.any { ancestor -> + ancestor.depth < node.depth && ancestor.bounds.contains(node.bounds) + } + if (!hasFocusableAncestor) result += node + } + + return result + } + + private fun com.composea11yscanner.core.model.Rect.contains(other: com.composea11yscanner.core.model.Rect): Boolean = + left <= other.left && + top <= other.top && + right >= other.right && + bottom >= other.bottom } diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt index cc13759..64c256a 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt @@ -22,9 +22,13 @@ class TextScalingRule( override fun evaluateAll(nodes: List): List = nodes - .filter { it.composableName.contains("Text", ignoreCase = true) } + .filter { + it.composableName.contains("Text", ignoreCase = true) && + !it.isMergedDescendant + } .mapNotNull { textNode -> val parent = findParent(textNode, nodes) ?: return@mapNotNull null + if (!parent.isTouchTarget) return@mapNotNull null val scaledHeight = textNode.bounds.height * scaleFactor val overflowPx = (textNode.bounds.top + scaledHeight) - parent.bounds.bottom 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 d3a3ca0..aba89d9 100644 --- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt @@ -14,6 +14,7 @@ class TouchTargetRule( override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)" override fun check(node: A11yNode): A11yIssue? { + if (node.isMergedDescendant) return null if (!node.isTouchTarget) return null val w = node.touchTargetSize.width diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt index e9a77a1..9d5bfcc 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt @@ -33,6 +33,11 @@ class ClickableRoleRuleTest { assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Switch))) } + @Test + fun `touch target with TextField role passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.TextField))) + } + @Test fun `touch target with Image role and non-empty description passes`() { assertNull( @@ -73,9 +78,8 @@ class ClickableRoleRuleTest { // --- edge cases --- @Test - fun `merged descendant that is a touch target with no role is still flagged`() { - // ClickableRoleRule does not exempt merged descendants — role is a per-node concern - assertNotNull( + fun `merged descendant that is a touch target with no role is skipped`() { + assertNull( rule.evaluate(createNode(isTouchTarget = true, role = null, isMergedDescendant = true)) ) } 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 0bcb514..ab8cbcf 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt @@ -2,6 +2,7 @@ package com.composea11yscanner.rules import com.composea11yscanner.core.model.A11ySeverity import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test 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 d85e0b3..0048acb 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt @@ -52,6 +52,28 @@ class FocusOrderRuleTest { assertTrue(rule.evaluateAll(nodes).isEmpty()) } + @Test + fun `merged descendant focusable nodes are skipped`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode( + isFocusable = true, + bounds = Rect(0, 0, 100, 50), + isMergedDescendant = true, + ), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `focusable child inside focusable parent is skipped`() { + val nodes = listOf( + createNode(isFocusable = true, depth = 0, bounds = Rect(0, 100, 200, 160)), + createNode(isFocusable = true, depth = 1, bounds = Rect(0, 110, 100, 140)), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + // --- failing cases --- @Test diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt index 2dcf735..7ef26f7 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt @@ -30,7 +30,7 @@ class TextScalingRuleTest { @Test fun `text node with sufficient room in parent passes`() { // scaledHeight = 100 * 1.3 = 130 < parent.bottom 200 → no overflow - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 200)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 200), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 100)) assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) } @@ -38,31 +38,43 @@ class TextScalingRuleTest { @Test fun `scaled height landing exactly on parent bottom passes`() { // 80 * 1.3 = 104.0; scaledBottom = 0 + 104 = 104 == parent.bottom 104 → overflowPx = 0 - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 104)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 104), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) } @Test fun `text node with zero height never overflows`() { - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 0)) assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) } + @Test + fun `merged descendant text node is skipped`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) + val text = createNode( + composableName = "Text", + depth = 1, + bounds = Rect(0, 0, 200, 80), + isMergedDescendant = true, + ) + assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) + } + // --- failing cases --- @Test fun `text node that overflows parent at 1_3x scale fails`() { // scaledHeight = 80 * 1.3 = 104; scaledBottom = 104 > parent.bottom 100 → overflow 4px - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) assertEquals(1, rule.evaluateAll(listOf(parent, text)).size) } @Test fun `BasicText composable name is recognised`() { - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "BasicText", depth = 1, bounds = Rect(0, 0, 200, 80)) assertEquals(1, rule.evaluateAll(listOf(parent, text)).size) } @@ -73,7 +85,7 @@ class TextScalingRuleTest { fun `tightest parent is selected when multiple candidates at depth minus 1`() { // tightParent area=20000 vs looseParent area=160000; tightParent is chosen // tightParent.bottom=100 < scaledBottom=104 → overflow flagged - val tightParent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val tightParent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val looseParent = createNode(depth = 0, bounds = Rect(0, 0, 400, 400)) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) assertEquals(1, rule.evaluateAll(listOf(tightParent, looseParent, text)).size) @@ -83,14 +95,14 @@ class TextScalingRuleTest { fun `custom scale factor changes overflow threshold`() { // At 2.0x: scaledHeight = 60 * 2 = 120 > parent.bottom 100 → overflow val rule2x = TextScalingRule(screenDensity = 1f, scaleFactor = 2.0f) - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 60)) assertEquals(1, rule2x.evaluateAll(listOf(parent, text)).size) } @Test fun `issue message contains original and scaled height in dp`() { - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) val issue = rule.evaluateAll(listOf(parent, text)).first() assertTrue(issue.message.contains("80dp")) // original @@ -104,7 +116,7 @@ class TextScalingRuleTest { @Test fun `issue carries correct rule metadata`() { - val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100), isTouchTarget = true) val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) val issue = rule.evaluateAll(listOf(parent, text)).first() assertEquals("text-scaling", issue.ruleId) 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 f8ec116..ecd0ec9 100644 --- a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt @@ -29,6 +29,19 @@ class TouchTargetRuleTest { assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(56f, 64f)))) } + @Test + fun `merged descendant touch target is skipped`() { + assertNull( + rule.evaluate( + createNode( + isTouchTarget = true, + touchTargetSize = DpSize(32f, 32f), + isMergedDescendant = true, + ) + ) + ) + } + // --- failing cases --- @Test diff --git a/scanner-ui/build.gradle.kts b/scanner-ui/build.gradle.kts index 237b79a..e81d4f1 100644 --- a/scanner-ui/build.gradle.kts +++ b/scanner-ui/build.gradle.kts @@ -1,8 +1,6 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.compose) - alias(libs.plugins.hilt) - alias(libs.plugins.ksp) alias(libs.plugins.kotlin.android) alias(libs.plugins.paparazzi) } @@ -33,6 +31,12 @@ android { buildFeatures { compose = true } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + dependencies { implementation(project(":scanner-core")) implementation(project(":scanner-rules")) @@ -52,8 +56,6 @@ dependencies { implementation(libs.androidx.compose.ui.unit) implementation(libs.androidx.compose.ui.util) - implementation(libs.hilt.android) - ksp(libs.hilt.android.compiler) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) diff --git a/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt new file mode 100644 index 0000000..63083a0 --- /dev/null +++ b/scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt @@ -0,0 +1,52 @@ +package com.composea11yscanner.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class A11yNodeExtractorTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun clickableContainerWithDescendantText_resolvesAsClickableText() { + composeRule.setContent { + Box(modifier = Modifier.clickable { }) { + Text("Go") + } + } + + composeRule.waitForIdle() + + val nodes = A11yNodeExtractor(Density(1f)) + .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) + val clickableNode = nodes.single { it.isTouchTarget && it.contentDescription == "Go" } + + assertEquals("ClickableText", clickableNode.composableName) + } + + @Test + fun unlabeledClickable_resolvesAsClickable() { + composeRule.setContent { + Box(modifier = Modifier.clickable { }) + } + + composeRule.waitForIdle() + + val nodes = A11yNodeExtractor(Density(1f)) + .extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()) + val clickableNode = nodes.single { it.isTouchTarget } + + assertEquals("Clickable", clickableNode.composableName) + assertTrue(clickableNode.contentDescription.isNullOrBlank()) + } +} diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt index 4df94bb..76aab1d 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt @@ -264,14 +264,14 @@ private fun ScannerOverlayContent( config: ScannerConfig, ) { var scannerState by remember { mutableStateOf(ScannerState.Idle) } - var selectedIssue by remember { mutableStateOf(null) } + var selectedIssues by remember { mutableStateOf(emptyList()) } DisposableEffect(Unit) { onDispose { controller.stopScan() } } LaunchedEffect(Unit) { controller.stateFlow.collect { state -> scannerState = state - if (state is ScannerState.Scanning) selectedIssue = null + if (state is ScannerState.Scanning) selectedIssues = emptyList() } } @@ -289,7 +289,7 @@ private fun ScannerOverlayContent( Box(modifier = Modifier.fillMaxSize()) { A11yIssueOverlay( scanResult = scanResult, - onIssueSelected = { selectedIssue = it }, + onIssuesSelected = { selectedIssues = it }, modifier = Modifier.fillMaxSize(), ) @@ -308,8 +308,8 @@ private fun ScannerOverlayContent( } IssueDetailPanel( - issue = selectedIssue, - onDismiss = { selectedIssue = null }, + issues = selectedIssues, + onDismiss = { selectedIssues = emptyList() }, modifier = Modifier.align(Alignment.BottomCenter), ) } 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 04bce69..1dd294d 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt @@ -60,35 +60,56 @@ class A11yNodeExtractor(private val density: Density) { private fun SemanticsNode.toA11yNode(depth: Int, isMergedDescendant: Boolean): A11yNode { val composeRole = config.getOrNull(SemanticsProperties.Role) - val bounds = boundsInRoot.toCoreRect() + val isTextInput = config.contains(SemanticsActions.SetText) + val isTouchTarget = config.contains(SemanticsActions.OnClick) + val textLabel = if (isTouchTarget || composeRole != null || isTextInput) { + collectTextLabel() + } else { + null + } + val visualBounds = boundsInRoot + val touchTargetBounds = layoutBoundsInRoot() + val bounds = visualBounds.toCoreRect() return A11yNode( nodeId = id.toString(), - composableName = resolveComposableName(composeRole), + composableName = resolveComposableName( + composeRole = composeRole, + isTouchTarget = isTouchTarget, + textLabel = textLabel, + ), bounds = bounds, contentDescription = config .getOrNull(SemanticsProperties.ContentDescription) - ?.joinToString(separator = ", "), - isTouchTarget = config.contains(SemanticsActions.OnClick), - touchTargetSize = bounds.toDpSize(), + ?.joinToString(separator = ", ") + ?: textLabel, + isTouchTarget = isTouchTarget, + touchTargetSize = touchTargetBounds.toDpSize(), textColor = null, // not available via semantics backgroundColors = emptyList(), // not available via semantics isFocusable = config.contains(SemanticsActions.OnClick) || config.contains(SemanticsActions.RequestFocus) - || composeRole != null, + || composeRole != null + || isTextInput, isMergedDescendant = isMergedDescendant, depth = depth, - role = composeRole?.toA11yRole(), + role = if (isTextInput) A11yRole.TextField else composeRole?.toA11yRole(), ) } /** * Resolves a human-readable composable name. - * Priority: explicit TestTag → inferred from Role → inferred from Text property → "Unknown". + * Priority: explicit TestTag -> inferred from Role -> inferred from Text/click semantics -> "Unknown". */ - private fun SemanticsNode.resolveComposableName(composeRole: Role?): String = + private fun SemanticsNode.resolveComposableName( + composeRole: Role?, + isTouchTarget: Boolean, + textLabel: String?, + ): String = config.getOrNull(SemanticsProperties.TestTag) - ?: when (composeRole) { + ?: if (config.contains(SemanticsActions.SetText)) { + "TextField" + } else when (composeRole) { Role.Button -> "Button" Role.Checkbox -> "Checkbox" Role.Switch -> "Switch" @@ -98,10 +119,43 @@ class A11yNodeExtractor(private val density: Density) { Role.DropdownList -> "DropdownList" else -> { val text = config.getOrNull(SemanticsProperties.Text) - if (!text.isNullOrEmpty()) "Text" else "Unknown" + when { + !text.isNullOrEmpty() -> "Text" + isTouchTarget && !textLabel.isNullOrBlank() -> "ClickableText" + isTouchTarget -> "Clickable" + else -> "Unknown" + } } } + private fun SemanticsNode.collectTextLabel(): String? { + val labels = mutableListOf() + collectTextLabelsInto(labels) + return labels + .joinToString(separator = " ") + .takeIf { it.isNotBlank() } + } + + private fun SemanticsNode.collectTextLabelsInto(labels: MutableList) { + config.getOrNull(SemanticsProperties.Text) + ?.mapNotNull { it.text.takeIf(String::isNotBlank) } + ?.let(labels::addAll) + + children.forEach { child -> + child.collectTextLabelsInto(labels) + } + } + + 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(), @@ -109,7 +163,7 @@ class A11yNodeExtractor(private val density: Density) { bottom = bottom.roundToInt(), ) - private fun Rect.toDpSize(): DpSize = with(density) { + private fun androidx.compose.ui.geometry.Rect.toDpSize(): DpSize = with(density) { DpSize( width = width.toDp().value, height = height.toDp().value, 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 fc95830..415276c 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Public SDK entry point for running accessibility scans. @@ -116,12 +117,20 @@ class A11yScannerController( ) scanJob = scope.launch { - engine.scan(nodeProvider()).collect { _state.emit(it) } + val nodes = withContext(Dispatchers.Main.immediate) { + nodeProvider() + } + engine.scan(nodes).collect { _state.emit(it) } } return _state.asSharedFlow() } + fun clearState() { + stopScan() + _state.tryEmit(ScannerState.Idle) + } + /** Cancels the current scan if one is running. The [Flow] returned by [startScan] stops emitting. */ fun stopScan() { scanJob?.cancel() 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 edcd376..1a43e2b 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt @@ -54,10 +54,11 @@ fun A11yScannerScaffold( scannerController: A11yScannerController, config: ScannerConfig, modifier: Modifier = Modifier, + issueOffsetY: Int = 0, content: @Composable () -> Unit, ) { var scannerState by remember { mutableStateOf(ScannerState.Idle) } - var selectedIssue by remember { mutableStateOf(null) } + var selectedIssues by remember { mutableStateOf(emptyList()) } // Cancel any in-flight scan when the scaffold leaves composition. DisposableEffect(Unit) { @@ -67,7 +68,7 @@ fun A11yScannerScaffold( LaunchedEffect(Unit) { scannerController.stateFlow.collect { state -> scannerState = state - if (state is ScannerState.Scanning) selectedIssue = null + if (state is ScannerState.Scanning) selectedIssues = emptyList() } } @@ -77,7 +78,7 @@ fun A11yScannerScaffold( if (config.autoScan) { scannerController.startScan() } else { - scannerController.stopScan() + scannerController.clearState() } } @@ -90,8 +91,9 @@ fun A11yScannerScaffold( // ── 2. Issue highlight overlay ─────────────────────────────────────── A11yIssueOverlay( scanResult = scanResult, - onIssueSelected = { selectedIssue = it }, + onIssuesSelected = { selectedIssues = it }, modifier = Modifier.fillMaxSize(), + issueOffsetY = issueOffsetY, ) // ── 3. Summary bar — slides down from the top once scanning starts ─── @@ -111,8 +113,8 @@ fun A11yScannerScaffold( // ── 4. Issue detail panel — slides up when an overlay box is tapped ── IssueDetailPanel( - issue = selectedIssue, - onDismiss = { selectedIssue = null }, + issues = selectedIssues, + onDismiss = { selectedIssues = emptyList() }, modifier = Modifier.align(Alignment.BottomCenter), ) } diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt index 87b2c45..ce53ea4 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt @@ -12,8 +12,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -52,6 +55,7 @@ import java.util.Locale private val ErrorChipColor = Color(0xFFD32F2F) private val WarningChipColor = Color(0xFFFFA000) private val InfoChipColor = Color(0xFF1976D2) +private val IssueListMaxHeight = 480.dp private fun A11ySeverity.toChipColor(): Color = when (this) { A11ySeverity.Error -> ErrorChipColor @@ -90,29 +94,57 @@ private fun String.toWcagUrl(): String { @Composable fun IssueDetailPanel( issue: A11yIssue?, + issues: List = issue?.let(::listOf).orEmpty(), onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { + val activeIssues = issues.ifEmpty { issue?.let(::listOf).orEmpty() } + val isVisible = activeIssues.isNotEmpty() // Keep the last non-null issue so the content stays visible during the exit slide. - var panelIssue by remember { mutableStateOf(issue) } - LaunchedEffect(issue) { if (issue != null) panelIssue = issue } + var panelIssues by remember { mutableStateOf(emptyList()) } + LaunchedEffect(activeIssues) { + if (activeIssues.isNotEmpty()) panelIssues = activeIssues + } + val displayIssues = activeIssues.ifEmpty { panelIssues } AnimatedVisibility( - visible = issue != null, + visible = isVisible, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), modifier = modifier, ) { - panelIssue?.let { PanelContent(issue = it, onDismiss = onDismiss) } + if (displayIssues.isNotEmpty()) { + PanelContent(issues = displayIssues, onDismiss = onDismiss) + } } } +@Composable +fun IssueDetailPanel( + issues: List, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + IssueDetailPanel( + issue = issues.firstOrNull(), + issues = issues, + onDismiss = onDismiss, + modifier = modifier, + ) +} + @Composable private fun PanelContent( - issue: A11yIssue, + issues: List, onDismiss: () -> Unit, ) { val uriHandler = LocalUriHandler.current + val primaryIssue = issues.first() + val panelTitle = if (issues.size == 1) { + primaryIssue.ruleName + } else { + "${issues.size} issues on ${primaryIssue.affectedNode.composableName}" + } Surface( shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), @@ -129,7 +161,7 @@ private fun PanelContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = issue.ruleName, + text = panelTitle, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) @@ -141,37 +173,66 @@ private fun PanelContent( } } - SeverityChip( - severity = issue.severity, + LazyColumn( modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp, bottom = 16.dp), - ) - - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + .fillMaxWidth() + .heightIn(max = IssueListMaxHeight), + ) { + itemsIndexed( + items = issues, + key = { index, issue -> "${issue.issueId}-$index" }, + ) { index, issue -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + IssueBlock( + issue = issue, + onWcagClick = { ref -> uriHandler.openUri(ref.toWcagUrl()) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + } + } +} - SectionBlock( - label = "Issue", - body = issue.message, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), +@Composable +private fun IssueBlock( + issue: A11yIssue, + onWcagClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = issue.ruleName, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), ) + SeverityChip(severity = issue.severity) + } - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + SectionBlock( + label = "Issue", + body = issue.message, + ) - SectionBlock( - label = "How to fix", - body = issue.howToFix, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) + SectionBlock( + label = "How to fix", + body = issue.howToFix, + ) - issue.wcagReference?.let { ref -> - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) - WcagLink( - reference = ref, - onClick = { uriHandler.openUri(ref.toWcagUrl()) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } + issue.wcagReference?.let { ref -> + WcagLink( + reference = ref, + onClick = { onWcagClick(ref) }, + ) } } } @@ -310,6 +371,39 @@ private fun IssueDetailPanelNoWcagPreview() { } } +@Preview(showBackground = true) +@Composable +private fun IssueDetailPanelMultipleIssuesPreview() { + MaterialTheme { + IssueDetailPanel( + issues = listOf( + previewIssue( + severity = A11ySeverity.Error, + ruleName = "Clickable Role", + message = "Clickable element does not expose a button role.", + howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.", + wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", + ), + previewIssue( + severity = A11ySeverity.Error, + ruleName = "Touch Target Size", + message = "Touch target is 28x28dp. Minimum required is 48x48dp.", + howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding.", + wcagReference = "WCAG 2.5.5 Target Size (Level AA)", + ), + previewIssue( + severity = A11ySeverity.Warning, + ruleName = "Missing Content Description", + message = "Interactive element has no content description.", + howToFix = "Add a meaningful contentDescription via semantics.", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ), + ), + onDismiss = {}, + ) + } +} + private fun previewIssue( severity: A11ySeverity, ruleName: String, diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt index 2f8cec7..e3f0d8f 100644 --- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt +++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt @@ -5,8 +5,11 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.tooling.preview.Preview import com.composea11yscanner.core.model.A11yIssue import com.composea11yscanner.core.model.A11yNode @@ -15,20 +18,15 @@ import com.composea11yscanner.core.model.DpSize import com.composea11yscanner.core.model.Rect import com.composea11yscanner.core.model.ScanResult -object ScannerUi { - const val VERSION = "0.1.0" -} - -@Composable -fun ScannerOverlay(modifier: Modifier = Modifier) { - // TODO: Implement scanner overlay -} - @Composable fun A11yIssueOverlay( scanResult: ScanResult?, onIssueSelected: (A11yIssue) -> Unit = {}, + onIssuesSelected: (List) -> Unit = { issues -> + issues.firstOrNull()?.let(onIssueSelected) + }, modifier: Modifier = Modifier, + issueOffsetY: Int = 0, ) { Box(modifier = modifier.fillMaxSize()) { AnimatedVisibility( @@ -36,7 +34,30 @@ fun A11yIssueOverlay( enter = fadeIn(), exit = fadeOut(), ) { - // TODO: Render issue markers over host content + Box(modifier = Modifier.fillMaxSize()) { + scanResult?.issues + .orEmpty() + .groupBy { it.affectedNode.nodeId } + .values + .forEach { issues -> + val primaryIssue = issues.minWith( + compareBy { it.severity.sortOrder } + .thenBy { it.ruleId }, + ) + IssueHighlightBox( + issue = primaryIssue, + onIssueSelected = { onIssuesSelected(issues) }, + modifier = Modifier + .align(Alignment.TopStart) + .offset { + IntOffset( + x = primaryIssue.affectedNode.bounds.left, + y = primaryIssue.affectedNode.bounds.top + issueOffsetY, + ) + }, + ) + } + } } } } diff --git a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt index bd92b63..7b8e2f3 100644 --- a/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt +++ b/scanner-ui/src/test/java/com/composea11yscanner/ui/IssueDetailPanelSnapshotTest.kt @@ -67,4 +67,41 @@ class IssueDetailPanelSnapshotTest { } } } + + @Test + fun multipleIssues_showScrollableList() { + paparazzi.snapshot { + MaterialTheme { + IssueDetailPanel( + issues = listOf( + issueFixture( + severity = A11ySeverity.Error, + issueId = "clickable-role", + ruleName = "Clickable Role", + message = "Clickable element does not expose a button role.", + howToFix = "Apply Modifier.clickable(role = Role.Button) for action controls.", + wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)", + ), + issueFixture( + severity = A11ySeverity.Error, + issueId = "touch-target-size", + ruleName = "Touch Target Size", + message = "Touch target is 28x28dp. Minimum required is 48x48dp.", + howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding.", + wcagReference = "WCAG 2.5.5 Target Size (Level AA)", + ), + issueFixture( + severity = A11ySeverity.Warning, + issueId = "missing-content-description", + ruleName = "Missing Content Description", + message = "Interactive element has no content description.", + howToFix = "Add a meaningful contentDescription via semantics.", + wcagReference = "WCAG 1.1.1 Non-text Content (Level A)", + ), + ), + onDismiss = {}, + ) + } + } + } }