11package expo.modules.ama
22
33import android.graphics.Color
4- import android.graphics.Rect
54import android.graphics.drawable.ColorDrawable
6- import android.util.DisplayMetrics
75import android.view.View
86import android.view.ViewGroup
97import android.widget.TextView
108import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
119import expo.modules.kotlin.AppContext
1210import java.util.Collections
11+ import kotlin.collections.mutableListOf
1312import kotlin.math.pow
13+ import kotlin.synchronized
1414
1515data class A11yIssue (
1616 val type : RuleAction ,
1717 val rule : Rule ,
1818 val label : String? = null ,
1919 val reason : String? = null ,
20- val bounds : List <Int >,
2120 val viewId : Int? = null ,
2221)
2322
@@ -81,8 +80,14 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
8180 this .rootView = rootView
8281
8382 rootView.let { root ->
83+ val oldIssues = synchronized(issues) { issues.toList() }
84+
85+ synchronized(issues) { issues.clear() }
86+
8487 traverseAndCheck(root)
8588
89+ clearFixedIssues(oldIssues)
90+
8691 if (issues.isNotEmpty()) {
8792 Logger .debug(" performA11yChecks" , issues.toString())
8893
@@ -93,7 +98,6 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
9398 " rule" to issue.rule,
9499 " label" to (issue.label ? : " " ),
95100 " reason" to (issue.reason ? : " " ),
96- " bounds" to issue.bounds,
97101 " viewId" to issue.viewId
98102 )
99103 }
@@ -105,11 +109,15 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
105109 return emptyList()
106110 }
107111
112+ private fun clearFixedIssues (oldIssues : List <A11yIssue >) {
113+ val fixed = oldIssues.filter { it !in issues }
114+
115+ fixed.forEach { issue -> issue.viewId?.let { highlighter.clearHighlight(it) } }
116+ }
117+
108118 private fun traverseAndCheck (view : View ) {
109119 val className = view.javaClass.name
110120
111- Logger .debug(" traverseAndCheck" , className)
112-
113121 // Ignores the debug overlay
114122 if (className.startsWith(" com.facebook.react.views.debuggingoverlay" )) {
115123 return
@@ -142,18 +150,17 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
142150 rule = rule,
143151 label = label,
144152 reason = reason,
145- bounds = view.getGlobalDpBounds(this .rootView),
146153 viewId = view.id
147154 )
148155 )
149156 }
150157 }
151158
152159 private fun checkView (view : View , issues : MutableList <A11yIssue >) {
153- if (view.isClickable) {
154- val info = view.createAccessibilityNodeInfo()
155- val a11yInfo = AccessibilityNodeInfoCompat .wrap(info)
160+ val info = view.createAccessibilityNodeInfo()
161+ val a11yInfo = AccessibilityNodeInfoCompat .wrap(info)
156162
163+ if (view.isPressable(a11yInfo)) {
157164 checkForA11yLabel(view, a11yInfo)
158165 checkForA11yRole(view, a11yInfo)
159166 checkForMinimumTargetSize(view)
@@ -195,13 +202,13 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
195202
196203 val role = roleDescription ? : defaultRole
197204
198- if (view.isClickable && role.isNullOrEmpty()) {
205+ if (role.isNullOrEmpty()) {
199206 Logger .error(" checkForA11yRole" , view.getTextOrContent())
200207
201208 addIssue(
202209 rule = Rule .NO_ACCESSIBILITY_ROLE ,
203210 label = view.getTextOrContent(),
204- reason = " is missing the accessibility role. " ,
211+ reason = " " ,
205212 view = view
206213 )
207214 }
@@ -214,8 +221,7 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
214221 addIssue(
215222 rule = Rule .MINIMUM_SIZE ,
216223 label = view.toString(),
217- reason =
218- " The touchable area must have a minimum size of 48x48, found ${view.width} x${view.height} instead" ,
224+ reason = " Touchable are found ${view.width} x${view.height} " ,
219225 view = view
220226 )
221227 }
@@ -243,8 +249,7 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
243249 addIssue(
244250 rule = Rule .CONTRAST_FAILED ,
245251 label = textView.toString(),
246- reason =
247- " Color contrast ratio ${String .format(" %.2f" , contrastRatio)} is below minimum ${minContrast} (WCAG AA)" ,
252+ reason = " Color contrast ratio ${String .format(" %.2f" , contrastRatio)} " ,
248253 view = textView
249254 )
250255 }
@@ -324,6 +329,20 @@ class A11yChecker(private val appContext: AppContext, private val config: AMACon
324329 }
325330}
326331
332+ fun View.isPressable (a11yInfo : AccessibilityNodeInfoCompat ): Boolean {
333+ return this .isClickable && this .isAccessible(a11yInfo)
334+ }
335+
336+ fun View.isAccessible (a11yInfo : AccessibilityNodeInfoCompat ): Boolean {
337+ if (this .importantForAccessibility == View .IMPORTANT_FOR_ACCESSIBILITY_NO ||
338+ ! a11yInfo.isVisibleToUser
339+ ) {
340+ return false
341+ }
342+
343+ return true
344+ }
345+
327346fun View.getTextOrContent (): String {
328347 if (! this .contentDescription.isNullOrEmpty()) {
329348 return this .contentDescription.toString()
@@ -344,25 +363,3 @@ fun View.getTextOrContent(): String {
344363
345364 return a11yInfo.contentDescription?.toString().orEmpty()
346365}
347-
348- private fun View.getGlobalDpBounds (rootView : View ): List <Int > {
349- // 1) grab absolute screen bounds
350- val abs = Rect ().also { createAccessibilityNodeInfo().getBoundsInScreen(it) }
351-
352- // 2) find your root’s absolute origin
353- val origin = IntArray (2 ).also { rootView.getLocationOnScreen(it) }
354- val relLeftPx = abs.left - origin[0 ]
355- val relTopPx = abs.top - origin[1 ]
356- val widthPx = abs.width()
357- val heightPx = abs.height()
358-
359- // 3) convert px → dp
360- val metrics: DisplayMetrics = resources.displayMetrics
361- val d = metrics.density
362- val leftDp = (relLeftPx / d).toInt()
363- val topDp = (relTopPx / d).toInt()
364- val widthDp = (widthPx / d).toInt()
365- val heightDp = (heightPx / d).toInt()
366-
367- return listOf (leftDp, topDp, widthDp, heightDp)
368- }
0 commit comments