@react-native-ama/core 2.0.0-beta.1 → 2.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,664 @@
1
+ package expo.modules.ama
2
+
3
+ import android.content.res.ColorStateList
4
+ import android.graphics.Color
5
+ import android.graphics.Rect
6
+ import android.graphics.drawable.*
7
+ import android.graphics.drawable.ColorDrawable
8
+ import android.os.Build
9
+ import android.text.Spanned
10
+ import android.text.TextPaint
11
+ import android.text.style.CharacterStyle
12
+ import android.text.style.ForegroundColorSpan
13
+ import android.text.style.TextAppearanceSpan
14
+ import android.view.View
15
+ import android.view.ViewGroup
16
+ import android.widget.TextView
17
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
18
+ import expo.modules.kotlin.AppContext
19
+
20
+ data class NodePayload(
21
+ val type: String,
22
+ val viewId: Int,
23
+ val bounds: Array<Float>?,
24
+ val ariaLabel: String?,
25
+ val content: String?,
26
+ val ariaRole: String?,
27
+ val fg: String?,
28
+ val bg: String?,
29
+ val fontSize: Float?,
30
+ val isBold: Boolean?,
31
+ val isEnabled: Boolean?,
32
+ val isAccessible: Boolean? = null,
33
+ val returnType: Int? = null
34
+ ) {
35
+ fun toMap(): Map<String, Any?> {
36
+ return mapOf(
37
+ "type" to this.type,
38
+ "viewId" to this.viewId,
39
+ "bounds" to this.bounds,
40
+ "ariaLabel" to this.ariaLabel,
41
+ "content" to this.content,
42
+ "ariaRole" to this.ariaRole,
43
+ "fg" to this.fg,
44
+ "bg" to this.bg,
45
+ "fontSize" to this.fontSize,
46
+ "isBold" to this.isBold,
47
+ "isEnabled" to this.isEnabled,
48
+ "isAccessible" to this.isAccessible,
49
+ "returnType" to this.returnType
50
+ )
51
+ }
52
+ }
53
+
54
+ enum class NodeType {
55
+ Pressable,
56
+ Text,
57
+ TextInput,
58
+ Image
59
+ }
60
+
61
+ class NodesGrabber(private val appContext: AppContext) {
62
+ val activity = appContext.activityProvider?.currentActivity
63
+
64
+ private var nodesToCheck = mutableMapOf<Int, NodePayload>()
65
+ private lateinit var rootView: View
66
+
67
+ fun getNodesToCheck(rootView: View): Map<Int, Any?> {
68
+ this.rootView = rootView
69
+
70
+ nodesToCheck.clear()
71
+
72
+ rootView.let { root -> traverseAndCheck(root) }
73
+
74
+ return nodesToCheck.mapValues { it.value.toMap() }
75
+ }
76
+
77
+ private fun traverseAndCheck(view: View) {
78
+ val className = view.javaClass.name
79
+
80
+ // Ignores the debug overlay
81
+ if (className.startsWith("com.facebook.react.views.debuggingoverlay")) {
82
+ return
83
+ }
84
+
85
+ val info = view.createAccessibilityNodeInfo()
86
+ val a11yInfo = AccessibilityNodeInfoCompat.wrap(info)
87
+
88
+ checkView(view, a11yInfo)
89
+
90
+ if (view.isPressable(a11yInfo)) {
91
+ return
92
+ }
93
+
94
+ if (view is ViewGroup) {
95
+ for (i in 0 until view.childCount) {
96
+ traverseAndCheck(view.getChildAt(i))
97
+ }
98
+ }
99
+ }
100
+
101
+ private fun addNode(node: NodePayload) {
102
+ if (node.viewId <= 0) {
103
+ return
104
+ }
105
+
106
+ nodesToCheck[node.viewId] = node
107
+ }
108
+
109
+ private fun checkView(view: View, a11yInfo: AccessibilityNodeInfoCompat) {
110
+ val isPressable = view.isPressable(a11yInfo)
111
+ val isTextLike = view.isTextLike()
112
+ val isImage = view.isImage()
113
+
114
+ if (!isPressable && !isTextLike && !isImage) {
115
+ return
116
+ }
117
+
118
+ val ariaRole = view.getAriaRole(a11yInfo)
119
+ val nodeType =
120
+ view.getNodeType(
121
+ a11yInfo = a11yInfo,
122
+ ariaRole = ariaRole,
123
+ isPressable = isPressable,
124
+ isTextLike = isTextLike,
125
+ isImage = isImage
126
+ )
127
+ val textInfo =
128
+ if (nodeType != NodeType.Pressable && nodeType != NodeType.Image)
129
+ view.extractRNTextInfo()
130
+ else null
131
+ val isAccessible =
132
+ if (nodeType == NodeType.Pressable) {
133
+ null
134
+ } else {
135
+ !view.hidesAccessibilityDescendants() && a11yInfo.isVisibleToUser
136
+ }
137
+
138
+ addNode(
139
+ NodePayload(
140
+ type = nodeType.name,
141
+ viewId = view.id,
142
+ bounds = getTargetArea(view, nodeType),
143
+ ariaLabel = a11yInfo.contentDescription?.toString(),
144
+ content =
145
+ when (nodeType) {
146
+ NodeType.Pressable -> view.getTextOrContent()
147
+ NodeType.Image -> view.getImageContent()
148
+ else -> textInfo?.text.orEmpty()
149
+ },
150
+ ariaRole = ariaRole,
151
+ fg =
152
+ when (nodeType) {
153
+ NodeType.Pressable -> view.getTextColorHex()
154
+ NodeType.Image -> null
155
+ else -> textInfo?.fg
156
+ },
157
+ bg = view.getBackgroundColorHex(),
158
+ fontSize =
159
+ if (nodeType == NodeType.Pressable) {
160
+ view.getFontSize()
161
+ } else {
162
+ textInfo?.fontSizeSp
163
+ },
164
+ isBold =
165
+ if (nodeType == NodeType.Pressable) {
166
+ view.isTextBold()
167
+ } else {
168
+ textInfo?.isBold == true
169
+ },
170
+ isEnabled =
171
+ if (nodeType == NodeType.Pressable) !view.isDisabled()
172
+ else view.isEnabled,
173
+ isAccessible = isAccessible,
174
+ returnType =
175
+ if (nodeType == NodeType.TextInput) {
176
+ view.getReturnType()
177
+ } else null
178
+ )
179
+ )
180
+ }
181
+
182
+ private val density: Float
183
+ get() = activity?.resources?.displayMetrics?.density ?: 1f
184
+
185
+ /** dp → px */
186
+ private fun dpToPx(dp: Float): Int = (dp * density + 0.5f).toInt()
187
+
188
+ /** px → dp */
189
+ private fun pxToDp(px: Int): Float = px / density
190
+
191
+ private fun getTargetArea(view: View, nodeType: NodeType): Array<Float> {
192
+ // Images always need bounds
193
+ val absBounds = Rect().also { view.createAccessibilityNodeInfo().getBoundsInScreen(it) }
194
+
195
+ // Only apply hit slop to pressable views
196
+ if (nodeType == NodeType.Pressable) {
197
+ view.getHitSlopRect()?.let { hitSlop ->
198
+ absBounds.left -= hitSlop.left
199
+ absBounds.top -= hitSlop.top
200
+ absBounds.right += hitSlop.right
201
+ absBounds.bottom += hitSlop.bottom
202
+ }
203
+ }
204
+
205
+ val widthPx = absBounds.width()
206
+ val heightPx = absBounds.height()
207
+ val widthDp: Float = widthPx / view.resources.displayMetrics.density
208
+ val heightDp: Float = heightPx / view.resources.displayMetrics.density
209
+
210
+ return arrayOf(widthDp, heightDp)
211
+ }
212
+ }
213
+
214
+ private fun View.getNodeType(
215
+ a11yInfo: AccessibilityNodeInfoCompat,
216
+ ariaRole: String?,
217
+ isPressable: Boolean,
218
+ isTextLike: Boolean,
219
+ isImage: Boolean
220
+ ): NodeType {
221
+ if (isTextInput(a11yInfo, ariaRole)) {
222
+ return NodeType.TextInput
223
+ }
224
+
225
+ if (isPressable) {
226
+ return NodeType.Pressable
227
+ }
228
+
229
+ if (isImage) {
230
+ return NodeType.Image
231
+ }
232
+
233
+ if (isTextLike) {
234
+ return NodeType.Text
235
+ }
236
+
237
+ throw IllegalStateException("Unsupported node type for viewId=$id")
238
+ }
239
+
240
+ private fun View.isTextInput(a11yInfo: AccessibilityNodeInfoCompat, ariaRole: String?): Boolean {
241
+ val normalizedRole = ariaRole?.trim()?.lowercase()
242
+ val className = a11yInfo.className?.toString() ?: javaClass.name
243
+
244
+ return normalizedRole == "text input" ||
245
+ normalizedRole == "text field" ||
246
+ this is android.widget.EditText ||
247
+ className.endsWith(".EditText") ||
248
+ className.endsWith("ReactEditText")
249
+ }
250
+
251
+ private fun View.getReturnType(): Int? {
252
+ if (this !is android.widget.EditText) {
253
+ return null
254
+ }
255
+ return this.imeOptions and android.view.inputmethod.EditorInfo.IME_MASK_ACTION
256
+ }
257
+
258
+ private fun View.hidesAccessibilityDescendants(): Boolean {
259
+ return importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO ||
260
+ importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
261
+ }
262
+
263
+ fun View.isPressable(a11yInfo: AccessibilityNodeInfoCompat): Boolean {
264
+ return this.isClickable && this.isAccessible(a11yInfo)
265
+ }
266
+
267
+ fun View.isAccessible(a11yInfo: AccessibilityNodeInfoCompat): Boolean {
268
+ if (this.hidesAccessibilityDescendants() || !a11yInfo.isVisibleToUser) {
269
+ return false
270
+ }
271
+
272
+ return true
273
+ }
274
+
275
+ fun View.getTextOrContent(): String {
276
+ if (!this.contentDescription.isNullOrEmpty()) {
277
+ return this.contentDescription.toString()
278
+ }
279
+
280
+ if (this is TextView) {
281
+ if (!this.text.isNullOrEmpty()) {
282
+ return this.text.toString()
283
+ }
284
+
285
+ if (!this.hint.isNullOrEmpty()) {
286
+ return this.hint.toString()
287
+ }
288
+ }
289
+
290
+ val info = createAccessibilityNodeInfo()
291
+ val a11yInfo = AccessibilityNodeInfoCompat.wrap(info)
292
+
293
+ return a11yInfo.contentDescription?.toString().orEmpty()
294
+ }
295
+
296
+ fun Int.toHex(): String {
297
+ return String.format("#%06X", 0xFFFFFF and this)
298
+ }
299
+
300
+ fun View.getTextColorHex(): String? = getTextColor()?.toHex()
301
+
302
+ fun View.getTextColor(): Int? {
303
+ return when (this) {
304
+ is TextView -> extractColorFromTextView(this)
305
+ is ViewGroup -> {
306
+ for (i in 0 until childCount) {
307
+ getChildAt(i).getTextColor()?.let {
308
+ return it
309
+ }
310
+ }
311
+ null
312
+ }
313
+ else -> null
314
+ }
315
+ }
316
+
317
+ private fun extractColorFromTextView(tv: TextView): Int {
318
+ val cs = tv.text
319
+ // Try spans at the first non-whitespace character
320
+ if (cs is Spanned && cs.isNotEmpty()) {
321
+ val idx = cs.toString().indexOfFirst { !it.isWhitespace() }.let { if (it < 0) 0 else it }
322
+
323
+ // 1) Standard ForegroundColorSpan
324
+ cs.getSpans(idx, idx + 1, ForegroundColorSpan::class.java).lastOrNull()?.let {
325
+ return it.foregroundColor
326
+ }
327
+
328
+ // 2) React Native span: ReactForegroundColorSpan (class name is stable across versions)
329
+ val spans = cs.getSpans(idx, idx + 1, CharacterStyle::class.java)
330
+ spans.asList().asReversed().forEach { span ->
331
+ runCatching {
332
+ val m = span.javaClass.getMethod("getForegroundColor")
333
+ (m.invoke(span) as? Int)
334
+ }
335
+ .getOrNull()
336
+ ?.let {
337
+ return it
338
+ }
339
+
340
+ // …then fall back to a common private field name
341
+ runCatching {
342
+ val f = span.javaClass.getDeclaredField("mColor").apply { isAccessible = true }
343
+ f.getInt(span)
344
+ }
345
+ .getOrNull()
346
+ ?.let {
347
+ return it
348
+ }
349
+ }
350
+
351
+ // 3) Some styles come via TextAppearanceSpan
352
+ cs.getSpans(idx, idx + 1, TextAppearanceSpan::class.java).lastOrNull()?.let { ta ->
353
+ val tp = TextPaint()
354
+ ta.updateDrawState(tp)
355
+ return tp.color
356
+ }
357
+ }
358
+
359
+ // 4) Fallback to TextView’s (stateful) ColorStateList
360
+ return tv.textColors?.getColorForState(tv.drawableState, tv.currentTextColor)
361
+ ?: tv.currentTextColor
362
+ }
363
+
364
+ fun View.getBackgroundColorHex(): String? = getBackgroundColor()?.toHex()
365
+
366
+ fun View.getBackgroundColor(): Int? {
367
+ extractSolidColor(background)?.let {
368
+ return it
369
+ }
370
+
371
+ /**
372
+ * <View bgcolor="xxx">
373
+ * ```
374
+ * <Text>Text goes here</Text>
375
+ * ```
376
+ * </View>
377
+ *
378
+ * Becomes:
379
+ *
380
+ * Container:
381
+ * ```
382
+ * ReactViewGroup <-- This has the bg colour
383
+ * ReactTextView
384
+ * ```
385
+ */
386
+ var p = parent
387
+ val vg = (parent as? ViewGroup)
388
+ vg?.let {
389
+ if (it.childCount > 0 && it.getChildAt(0) is ViewGroup) {
390
+ extractSolidColor(it.getChildAt(0).background)?.let {
391
+ return it
392
+ }
393
+ }
394
+ }
395
+
396
+ while (p is View) {
397
+ extractSolidColor(p.background)?.let {
398
+ return it
399
+ }
400
+
401
+ p = p.parent
402
+ }
403
+
404
+ return null
405
+ }
406
+
407
+ private fun extractSolidColor(d: Drawable?): Int? =
408
+ when (d) {
409
+ null -> null
410
+ is ColorDrawable -> d.color
411
+ is GradientDrawable -> d.color?.defaultColor
412
+ is StateListDrawable -> extractSolidColor(d.current)
413
+ is InsetDrawable -> extractSolidColor(d.drawable)
414
+ is LayerDrawable -> {
415
+ for (i in 0 until d.numberOfLayers) {
416
+ extractSolidColor(d.getDrawable(i))?.let {
417
+ return it
418
+ }
419
+ }
420
+ null
421
+ }
422
+ is RippleDrawable -> {
423
+ runCatching { extractSolidColor(d.getDrawable(0)) }.getOrNull()
424
+ }
425
+ else ->
426
+ extractRNBackgroundDrawableColor(
427
+ d
428
+ ) // 👈 handle RN BackgroundDrawable/ReactViewBackgroundDrawable
429
+ }
430
+
431
+ /** Works with RN ≥0.7x `BackgroundDrawable` and legacy `ReactViewBackgroundDrawable`. */
432
+ private fun extractRNBackgroundDrawableColor(d: Drawable): Int? {
433
+ val cls = d.javaClass
434
+ val name = cls.name
435
+ if (!name.endsWith("BackgroundDrawable")) return null
436
+
437
+ // Try public getters first (some versions expose these)
438
+ runCatching { cls.getMethod("getColor").invoke(d) }.getOrNull()?.let {
439
+ return asColorInt(it)
440
+ }
441
+ runCatching { cls.getMethod("getBackgroundColor").invoke(d) }.getOrNull()?.let {
442
+ return asColorInt(it)
443
+ }
444
+
445
+ // Fall back to common private fields across versions
446
+ val candidateFields = arrayOf("mColor", "mBackgroundColor")
447
+ for (fName in candidateFields) {
448
+ val v =
449
+ runCatching { cls.getDeclaredField(fName).apply { isAccessible = true }.get(d) }
450
+ .getOrNull()
451
+ asColorInt(v)?.let {
452
+ return it
453
+ }
454
+ }
455
+
456
+ return null
457
+ }
458
+
459
+ private fun asColorInt(value: Any?): Int? =
460
+ when (value) {
461
+ is Int -> value.takeIf { Color.alpha(it) != 0 }
462
+ is ColorStateList -> value.defaultColor.takeIf { Color.alpha(it) != 0 }
463
+ else -> null
464
+ }
465
+
466
+ fun View.getFontSize(): Float? {
467
+ if (this is TextView) {
468
+ return this.textSize / this.context.resources.displayMetrics.scaledDensity
469
+ }
470
+
471
+ return null
472
+ }
473
+
474
+ fun View.getHitSlopRect(): Rect? {
475
+ return try {
476
+ val rvClass = Class.forName("com.facebook.react.views.view.ReactViewGroup")
477
+
478
+ if (!rvClass.isInstance(this)) {
479
+ return null
480
+ }
481
+
482
+ val getter = rvClass.getMethod("getHitSlopRect")
483
+
484
+ @Suppress("UNCHECKED_CAST") val rect = getter.invoke(this) as? Rect
485
+
486
+ rect
487
+ } catch (e: ClassNotFoundException) {
488
+ null
489
+ } catch (e: NoSuchMethodException) {
490
+ null
491
+ } catch (e: Exception) {
492
+ null
493
+ }
494
+ }
495
+
496
+ fun View.getAriaRole(a11yInfo: AccessibilityNodeInfoCompat): String? {
497
+ if (a11yInfo.isHeading) return "header"
498
+ if (a11yInfo.collectionItemInfo?.isHeading == true) return "header"
499
+ if (Build.VERSION.SDK_INT >= 28 && this is TextView && this.isAccessibilityHeading)
500
+ return "header"
501
+
502
+ val roleDescription: String? = a11yInfo.roleDescription?.toString()
503
+ val className = a11yInfo.className?.toString() ?: this.javaClass.name
504
+ val defaultRole =
505
+ when {
506
+ className.endsWith(".Button") -> "button"
507
+ className.endsWith(".CheckBox") -> "checkbox"
508
+ className.endsWith(".Switch") -> "switch"
509
+ className.endsWith(".EditText") -> "text field"
510
+ className.endsWith(".ImageView") && isClickable -> "image button"
511
+ else -> null
512
+ }
513
+
514
+ return roleDescription ?: defaultRole
515
+ }
516
+
517
+ fun View.isTextBold(): Boolean {
518
+ if (this is TextView) {
519
+ val typeface = this.typeface
520
+ return typeface != null && typeface.isBold
521
+ }
522
+
523
+ return false
524
+ }
525
+
526
+ fun View.isDisabled(): Boolean {
527
+ val info = AccessibilityNodeInfoCompat.wrap(this.createAccessibilityNodeInfo())
528
+
529
+ val disabledByA11y = !info.isEnabled
530
+ val disabledByView = !this.isEnabled
531
+
532
+ val hasClickAction = info.isClickable
533
+
534
+ val pointerEventsDisabled =
535
+ getRNPointerEvents(this)?.let { it == "NONE" || it == "BOX_NONE" } == true
536
+
537
+ val interactionBlocked = (this.isClickable && !hasClickAction) || pointerEventsDisabled
538
+
539
+ return disabledByA11y || disabledByView || interactionBlocked
540
+ }
541
+
542
+ data class TextInfo(
543
+ val text: String,
544
+ val fg: String?,
545
+ val bg: String?,
546
+ val fontSizeSp: Float?,
547
+ val isBold: Boolean
548
+ )
549
+
550
+ fun View.isTextLike(): Boolean {
551
+ // Common Android + RN text classes
552
+ val candidateClassNames =
553
+ listOf(
554
+ "android.widget.TextView",
555
+ "androidx.appcompat.widget.AppCompatTextView",
556
+ "com.google.android.material.textview.MaterialTextView",
557
+ "com.facebook.react.views.text.ReactTextView",
558
+ "com.facebook.react.views.text.ReactVirtualTextView",
559
+ "com.facebook.react.views.textinput.ReactEditText"
560
+ )
561
+ // 1) class match
562
+ for (name in candidateClassNames) {
563
+ runCatching { Class.forName(name) }.getOrNull()?.let { cls ->
564
+ if (cls.isInstance(this)) return true
565
+ }
566
+ }
567
+ // 2) “duck typing”: has a getText(): CharSequence?
568
+ return this.javaClass.methods.any { m ->
569
+ m.name == "getText" &&
570
+ m.parameterCount == 0 &&
571
+ (m.returnType == CharSequence::class.java ||
572
+ CharSequence::class.java.isAssignableFrom(m.returnType))
573
+ }
574
+ }
575
+
576
+ fun View.isImage(): Boolean {
577
+ // Common Android + RN image classes
578
+ val candidateClassNames =
579
+ listOf(
580
+ "android.widget.ImageView",
581
+ "androidx.appcompat.widget.AppCompatImageView",
582
+ "com.facebook.react.views.image.ReactImageView"
583
+ )
584
+ // 1) class match
585
+ for (name in candidateClassNames) {
586
+ runCatching { Class.forName(name) }.getOrNull()?.let { cls ->
587
+ if (cls.isInstance(this)) return true
588
+ }
589
+ }
590
+ return false
591
+ }
592
+
593
+ fun View.getImageContent(): String {
594
+ // Try content description first (primary accessibility text on Android)
595
+ if (!contentDescription.isNullOrEmpty()) {
596
+ return contentDescription.toString()
597
+ }
598
+
599
+ // Try to extract text from child views (some React Native images have text overlays)
600
+ if (this is ViewGroup) {
601
+ for (i in 0 until childCount) {
602
+ val child = getChildAt(i)
603
+ if (child is TextView) {
604
+ val text = child.text?.toString()
605
+ if (!text.isNullOrEmpty()) {
606
+ return text
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ return ""
613
+ }
614
+
615
+ fun View.extractRNTextInfo(): TextInfo? {
616
+ if (this is android.widget.TextView) {
617
+ val density = resources.displayMetrics.scaledDensity
618
+ val fg = getTextColorHex()
619
+ val bg = getBackgroundColorHex()
620
+ val isBold = typeface?.isBold == true
621
+ val sp = textSize / density // textSize is px
622
+
623
+ return TextInfo(
624
+ text = text?.toString().orEmpty(),
625
+ fg = fg,
626
+ bg = bg,
627
+ fontSizeSp = sp,
628
+ isBold = isBold
629
+ )
630
+ }
631
+
632
+ if (this is ViewGroup) {
633
+ for (i in 0 until childCount) {
634
+ val t = getChildAt(i).extractRNTextInfo()
635
+ if (t != null) return t
636
+ }
637
+ }
638
+
639
+ val txt =
640
+ runCatching { this.javaClass.getMethod("getText").invoke(this) as? CharSequence }
641
+ .getOrNull()
642
+ return txt?.let {
643
+ TextInfo(
644
+ text = it.toString(),
645
+ fg = getTextColorHex(),
646
+ bg = getBackgroundColorHex(),
647
+ fontSizeSp = null,
648
+ isBold = false
649
+ )
650
+ }
651
+ }
652
+
653
+ private fun getRNPointerEvents(view: View): String? {
654
+ try {
655
+ val cls = Class.forName("com.facebook.react.views.view.ReactViewGroup")
656
+ if (!cls.isInstance(view)) return null
657
+ val m = cls.getMethod("getPointerEvents")
658
+ m.invoke(view)?.toString()
659
+ } catch (_: Throwable) {
660
+ null
661
+ }
662
+
663
+ return null
664
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android", "web"],
3
+ "apple": {
4
+ "modules": ["ReactNativeAmaModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.ama.ReactNativeAmaModule"]
8
+ }
9
+ }