Skip to content

Commit 8e8c2ce

Browse files
committed
make clear error
1 parent ce134e1 commit 8e8c2ce

20 files changed

Lines changed: 480 additions & 259 deletions

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module.exports = {
99
rules: {
1010
'@typescript-eslint/no-unused-vars': 'off',
1111
'react-hooks/rules-of-hooks': 'off',
12+
'@typescript-eslint/func-call-spacing': 'off',
1213
},
1314
parserOptions: {
1415
project: ['./tsconfig.json'],

packages/core/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package expo.modules.ama
22

33
import android.app.Activity
4+
import android.graphics.Rect
45
import android.os.Handler
56
import android.os.Looper
7+
import android.util.DisplayMetrics
68
import android.view.View
9+
import android.view.ViewGroup
710
import android.view.ViewTreeObserver
11+
import android.widget.ScrollView
12+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
813
import expo.modules.kotlin.modules.Module
914
import expo.modules.kotlin.modules.ModuleDefinition
15+
import kotlin.math.max
1016

1117
object Constants {
1218
const val DEBOUNCE: Long = 100
@@ -65,6 +71,65 @@ class ReactNativeAmaModule : Module() {
6571
isMonitoring = false
6672
}
6773
}
74+
75+
AsyncFunction("getPosition") { viewId: Int ->
76+
val activity = appContext.activityProvider?.currentActivity ?: return@AsyncFunction null
77+
val root = activity.window.decorView as? ViewGroup ?: return@AsyncFunction null
78+
val target = root.findViewById<View>(viewId) ?: return@AsyncFunction null
79+
80+
// 1) Scroll into view if in a ScrollView
81+
val scroll =
82+
generateSequence(target.parent) { (it as? View)?.parent }
83+
.filterIsInstance<ScrollView>()
84+
.firstOrNull()
85+
scroll?.let { sv ->
86+
val frame = Rect().apply { target.getDrawingRect(this) }
87+
sv.offsetDescendantRectToMyCoords(target, frame)
88+
val mPx = (10 * activity.resources.displayMetrics.density).toInt()
89+
frame.top = max(0, frame.top - mPx)
90+
sv.scrollTo(frame.left, frame.top)
91+
}
92+
93+
target.getGlobalDpBounds(root)
94+
}
95+
96+
AsyncFunction("inspectViewAttributes") { viewId: Int ->
97+
val activity: Activity =
98+
appContext.activityProvider?.currentActivity ?: return@AsyncFunction null
99+
100+
val root = activity.window.decorView as? ViewGroup ?: return@AsyncFunction null
101+
102+
// find the view
103+
val view = root.findViewById<View>(viewId) ?: return@AsyncFunction null
104+
105+
// wrap its AccessibilityNodeInfo for compat APIs
106+
val info = AccessibilityNodeInfoCompat.wrap(view.createAccessibilityNodeInfo())
107+
108+
// get raw screen bounds
109+
val loc = IntArray(2)
110+
view.getLocationOnScreen(loc)
111+
val bounds =
112+
mapOf(
113+
"left" to loc[0],
114+
"top" to loc[1],
115+
"right" to loc[0] + view.width,
116+
"bottom" to loc[1] + view.height
117+
)
118+
119+
mapOf<String, Any?>(
120+
"id" to view.id,
121+
"className" to view.javaClass.name,
122+
"visibility" to view.visibility, // 0=VISIBLE,4=INVISIBLE,8=GONE
123+
"importantForAccessibility" to view.importantForAccessibility,
124+
"clickable" to view.isClickable,
125+
"focusable" to view.isFocusable,
126+
"contentDescription" to view.contentDescription?.toString(),
127+
"a11yText" to info.text?.toString(),
128+
"a11yRoleDescription" to info.roleDescription?.toString(),
129+
"isVisibleToUser" to info.isVisibleToUser,
130+
"bounds" to bounds
131+
)
132+
}
68133
}
69134

70135
private fun getCurrentActivity(): Activity? {
@@ -106,3 +171,22 @@ class ReactNativeAmaModule : Module() {
106171
)
107172
}
108173
}
174+
175+
private fun View.getGlobalDpBounds(rootView: View): List<Int> {
176+
val abs = Rect().also { createAccessibilityNodeInfo().getBoundsInScreen(it) }
177+
178+
val origin = IntArray(2).also { rootView.getLocationOnScreen(it) }
179+
val relLeftPx = abs.left - origin[0]
180+
val relTopPx = abs.top - origin[1]
181+
val widthPx = abs.width()
182+
val heightPx = abs.height()
183+
184+
val metrics: DisplayMetrics = resources.displayMetrics
185+
val d = metrics.density
186+
val leftDp = (relLeftPx / d).toInt()
187+
val topDp = (relTopPx / d).toInt()
188+
val widthDp = (widthPx / d).toInt()
189+
val heightDp = (heightPx / d).toInt()
190+
191+
return listOf(leftDp, topDp, widthDp, heightDp)
192+
}

packages/core/android/src/debug/java/expo/modules/ama/a11yChecker.kt

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
package expo.modules.ama
22

33
import android.graphics.Color
4-
import android.graphics.Rect
54
import android.graphics.drawable.ColorDrawable
6-
import android.util.DisplayMetrics
75
import android.view.View
86
import android.view.ViewGroup
97
import android.widget.TextView
108
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
119
import expo.modules.kotlin.AppContext
1210
import java.util.Collections
11+
import kotlin.collections.mutableListOf
1312
import kotlin.math.pow
13+
import kotlin.synchronized
1414

1515
data 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+
327346
fun 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-
}

packages/core/android/src/debug/java/expo/modules/ama/highlight.kt

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.graphics.drawable.ShapeDrawable
88
import android.graphics.drawable.shapes.RectShape
99
import android.view.View
1010
import expo.modules.kotlin.AppContext
11+
import java.util.concurrent.ConcurrentHashMap
1112

1213
val ruleColors: Map<RuleAction, Int> =
1314
mapOf(
@@ -17,6 +18,8 @@ val ruleColors: Map<RuleAction, Int> =
1718
)
1819

1920
class Highlight(private val appContext: AppContext) {
21+
private val originalBackgrounds = ConcurrentHashMap<Int, Drawable?>()
22+
2023
/**
2124
* Highlight the view with the given ID.
2225
* @param viewId the Android view ID
@@ -29,6 +32,9 @@ class Highlight(private val appContext: AppContext) {
2932
activity.runOnUiThread {
3033
val target = activity.findViewById<View>(viewId) ?: return@runOnUiThread
3134

35+
val view = activity.findViewById<View>(viewId) ?: return@runOnUiThread
36+
originalBackgrounds.putIfAbsent(viewId, view.background)
37+
3238
when (mode) {
3339
"background" -> applyStripyBackground(target, color)
3440
"border" -> applyRedBorderOverlay(target, color)
@@ -40,46 +46,24 @@ class Highlight(private val appContext: AppContext) {
4046
}
4147
}
4248

43-
private fun applyGridBackground(view: View, color: Int?) {
44-
val stripeSize = 40
45-
val bmp = Bitmap.createBitmap(stripeSize * 2, stripeSize * 2, Bitmap.Config.ARGB_8888)
46-
val canvas = Canvas(bmp)
47-
val paint =
48-
Paint().apply {
49-
style = Paint.Style.FILL
50-
}
51-
canvas.drawRect(0f, 0f, stripeSize.toFloat(), stripeSize.toFloat(), paint)
52-
canvas.drawRect(
53-
stripeSize.toFloat(),
54-
stripeSize.toFloat(),
55-
stripeSize * 2f,
56-
stripeSize * 2f,
57-
paint
58-
)
49+
fun clearHighlight(viewId: Int) {
50+
val activity: Activity = appContext.currentActivity ?: return
51+
activity.runOnUiThread {
52+
val view = activity.findViewById<View>(viewId) ?: return@runOnUiThread
5953

60-
val shader = BitmapShader(bmp, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
61-
val bgPaint = Paint().apply { this.shader = shader }
62-
val drawable =
63-
object : Drawable() {
64-
override fun draw(c: Canvas) = c.drawRect(bounds, bgPaint)
65-
override fun setAlpha(alpha: Int) {
66-
bgPaint.alpha = alpha
67-
}
68-
override fun setColorFilter(cf: ColorFilter?) {
69-
bgPaint.colorFilter = cf
70-
}
71-
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
72-
}
54+
val original = originalBackgrounds.remove(viewId)
55+
view.background = original
7356

74-
view.background = drawable
75-
}
57+
view.overlay.clear()
58+
}
59+
}
7660

7761
private fun applyStripyBackground(view: View, color: Int?) {
7862
val originalBackground = view.background
7963

8064
val stripeWidth = 5f
8165
val gapWidth = 25f
82-
val bmpWidth = 150
66+
val bmpWidth = 150
8367
val bmpHeight = bmpWidth
8468

8569
val bmp = Bitmap.createBitmap(bmpWidth, bmpHeight, Bitmap.Config.ARGB_8888)
@@ -88,7 +72,7 @@ class Highlight(private val appContext: AppContext) {
8872
val stripePaint =
8973
Paint().apply {
9074
this.color = color ?: Color.RED
91-
isAntiAlias = true
75+
isAntiAlias = true
9276
}
9377

9478
val canvasCenter = bmpWidth / 2f
@@ -138,7 +122,7 @@ class Highlight(private val appContext: AppContext) {
138122
private fun applyRedBorderOverlay(view: View, color: Int?) {
139123
view.overlay.clear()
140124

141-
val stroke = 6f
125+
val stroke = 6f
142126
val half = (stroke / 2).toInt()
143127
val shape =
144128
ShapeDrawable(RectShape()).apply {

0 commit comments

Comments
 (0)