@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.
- package/ama.config.json +17 -0
- package/android/build.gradle +43 -0
- package/android/src/debug/AndroidManifest.xml +2 -0
- package/android/src/debug/java/expo/modules/ama/ReactNativeAmaModule.kt +544 -0
- package/android/src/debug/java/expo/modules/ama/ReactNativeAmaView.kt +30 -0
- package/android/src/debug/java/expo/modules/ama/highlight.kt +189 -0
- package/android/src/debug/java/expo/modules/ama/logger.kt +25 -0
- package/android/src/debug/java/expo/modules/ama/nodesGrabber.kt +664 -0
- package/expo-module.config.json +9 -0
- package/ios/Highlight.swift +264 -0
- package/ios/Logger.swift +31 -0
- package/ios/NodesGrabber.swift +733 -0
- package/ios/ReactNativeAma.podspec +29 -0
- package/ios/ReactNativeAmaModule.swift +568 -0
- package/ios/ReactNativeAmaView.swift +38 -0
- package/ios/TapGesture.swift +107 -0
- package/package.json +8 -2
|
@@ -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
|
+
}
|