@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,17 @@
1
+ {
2
+ "rules": {},
3
+ "accessibilityLabelExceptions": [],
4
+ "highlight": {
5
+ "mode": "both",
6
+ "borderWidth": 3,
7
+ "gap": 4
8
+ },
9
+ "log": "inspect",
10
+ "uppercaseMinLength": 4,
11
+ "longNumberMinLength": 12,
12
+ "checks": {
13
+ "ui": true,
14
+ "forms": true,
15
+ "delay": "1000"
16
+ }
17
+ }
@@ -0,0 +1,43 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.ama'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
13
+ // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
14
+ // Most of the time, you may like to manage the Android SDK versions yourself.
15
+ def useManagedAndroidSdkVersions = false
16
+ if (useManagedAndroidSdkVersions) {
17
+ useDefaultAndroidSdkVersions()
18
+ } else {
19
+ buildscript {
20
+ // Simple helper that allows the root project to override versions declared by this library.
21
+ ext.safeExtGet = { prop, fallback ->
22
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
23
+ }
24
+ }
25
+ project.android {
26
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
27
+ defaultConfig {
28
+ minSdkVersion safeExtGet("minSdkVersion", 21)
29
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
30
+ }
31
+ }
32
+ }
33
+
34
+ android {
35
+ namespace "expo.modules.ama"
36
+ defaultConfig {
37
+ versionCode 1
38
+ versionName "0.1.0"
39
+ }
40
+ lintOptions {
41
+ abortOnError false
42
+ }
43
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,544 @@
1
+ package expo.modules.ama
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.graphics.Rect
6
+ import android.os.Bundle
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.util.DisplayMetrics
10
+ import android.view.MotionEvent
11
+ import android.view.View
12
+ import android.view.ViewGroup
13
+ import android.view.ViewTreeObserver
14
+ import android.view.Window
15
+ import android.widget.Checkable
16
+ import android.widget.ScrollView
17
+ import androidx.core.view.ViewCompat
18
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
19
+ import expo.modules.kotlin.modules.Module
20
+ import expo.modules.kotlin.modules.ModuleDefinition
21
+ import kotlin.math.max
22
+
23
+ object Constants {
24
+ const val DEBOUNCE: Long = 100
25
+ var uiCheckDelay: Long = 1000
26
+ }
27
+
28
+ class ReactNativeAmaModule : Module() {
29
+ private var isMonitoring = false
30
+ private var currentDecorView: View? = null
31
+ private val drawListener = ViewTreeObserver.OnDrawListener { scheduleA11yCheck() }
32
+
33
+ private val checkHandler = Handler(Looper.getMainLooper())
34
+ private var checkRunnable: Runnable? = null
35
+ private lateinit var a11yChecker: NodesGrabber
36
+ private var highlighter: Highlight? = null
37
+ private var gap: Int = 0
38
+ private var borderWidth: Float = 6f
39
+
40
+ override fun definition() = ModuleDefinition {
41
+ Name("ReactNativeAma")
42
+
43
+ Events("onAmaNodes", "onUIInteraction")
44
+
45
+ Function("start") { args: Map<String, Any?>? ->
46
+ val uiCheck = args?.get("ui") as? Boolean ?: false
47
+ Constants.uiCheckDelay = args?.get("delay") as? Long ?: Constants.uiCheckDelay
48
+ gap = (args?.get("gap") as? Number)?.toInt() ?: gap
49
+ borderWidth = (args?.get("borderWidth") as? Number)?.toFloat() ?: borderWidth
50
+
51
+ if (highlighter == null) {
52
+ highlighter = Highlight(appContext)
53
+ }
54
+
55
+ if (!isMonitoring) {
56
+ Logger.info("start", "👀 Start Monitoring 👀" + args.toString())
57
+
58
+ a11yChecker = NodesGrabber(appContext)
59
+
60
+ val activity = getCurrentActivity()
61
+
62
+ if (uiCheck) {
63
+ attachWindowTapProbe(activity)
64
+ }
65
+
66
+ activity?.window?.decorView?.let {
67
+ currentDecorView = it
68
+ it.viewTreeObserver.addOnDrawListener(drawListener)
69
+ }
70
+
71
+ isMonitoring = true
72
+ }
73
+ }
74
+
75
+ Function("stop") {
76
+ if (isMonitoring) {
77
+ currentDecorView?.let { it.viewTreeObserver.removeOnDrawListener(drawListener) }
78
+
79
+ isMonitoring = false
80
+ }
81
+ }
82
+
83
+ AsyncFunction("highlight") { viewId: Int, mode: String, hexColor: String, issueCount: Int ->
84
+ val activity = appContext.activityProvider?.currentActivity ?: return@AsyncFunction null
85
+ val root = activity.window.decorView as? ViewGroup ?: return@AsyncFunction null
86
+ val target = root.findViewById<View>(viewId) ?: return@AsyncFunction null
87
+
88
+ val scroll =
89
+ generateSequence(target.parent) { (it as? View)?.parent }
90
+ .filterIsInstance<ScrollView>()
91
+ .firstOrNull()
92
+ scroll?.let { sv ->
93
+ val frame = Rect().apply { target.getDrawingRect(this) }
94
+ sv.offsetDescendantRectToMyCoords(target, frame)
95
+
96
+ val topVisible = frame.top >= 0
97
+ val bottomVisible = frame.bottom <= sv.height
98
+
99
+ if (!topVisible || !bottomVisible) {
100
+ val mPx = (10 * activity.resources.displayMetrics.density).toInt()
101
+ val scrollToY =
102
+ when {
103
+ frame.top < 0 -> max(0, frame.top - mPx)
104
+ frame.bottom > sv.height -> frame.bottom - sv.height + mPx
105
+ else -> sv.scrollY
106
+ }
107
+ sv.scrollTo(sv.scrollX, scrollToY)
108
+ }
109
+ }
110
+
111
+ highlighter?.highlight(viewId, mode, hexColor, gap, borderWidth, issueCount)
112
+
113
+ target.getGlobalDpBounds(root)
114
+ }
115
+
116
+ AsyncFunction("clearHighlight") { viewId: Int -> highlighter?.clearHighlight(viewId) }
117
+ }
118
+
119
+ private fun attachWindowTapProbe(activity: Activity?) {
120
+ if (activity == null) {
121
+ return
122
+ }
123
+
124
+ val originalCallback = activity.window.callback
125
+ val rootView = activity.window.decorView.rootView
126
+
127
+ activity.window.callback =
128
+ object : Window.Callback by originalCallback {
129
+
130
+ // Simple tap detection state
131
+ private var downX = 0f
132
+ private var downY = 0f
133
+ private val TAP_THRESHOLD = 20f // Max movement (in pixels) for a "tap"
134
+
135
+ override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
136
+ if (event != null) {
137
+ when (event.action) {
138
+ MotionEvent.ACTION_DOWN -> {
139
+ // Store tap start location
140
+ downX = event.x
141
+ downY = event.y
142
+ }
143
+ MotionEvent.ACTION_UP -> {
144
+ val isTap =
145
+ (Math.abs(event.x - downX) < TAP_THRESHOLD) &&
146
+ (Math.abs(event.y - downY) < TAP_THRESHOLD)
147
+
148
+ if (isTap) {
149
+ val tappedView =
150
+ findViewAt(
151
+ rootView,
152
+ event.rawX.toInt(),
153
+ event.rawY.toInt()
154
+ )
155
+
156
+ val info = tappedView?.createAccessibilityNodeInfo()
157
+ val a11yInfo =
158
+ info?.let { AccessibilityNodeInfoCompat.wrap(it) }
159
+ val isAccessible =
160
+ a11yInfo?.let { tappedView.isAccessible(it) }
161
+
162
+ if (tappedView != null &&
163
+ tappedView.isClickable &&
164
+ isAccessible == true
165
+ ) {
166
+ runMyChecks(tappedView, rootView)
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return originalCallback.dispatchTouchEvent(event)
174
+ }
175
+ }
176
+ }
177
+
178
+ private fun getCurrentActivity(): Activity? {
179
+ return appContext.activityProvider?.currentActivity
180
+ }
181
+
182
+ private fun scheduleA11yCheck() {
183
+ checkRunnable?.let { checkHandler.removeCallbacks(it) }
184
+ checkRunnable = Runnable { getNodesToCheck() }
185
+
186
+ checkHandler.postDelayed(checkRunnable!!, Constants.DEBOUNCE)
187
+ }
188
+
189
+ private fun getNodesToCheck(send: Boolean = true): Map<String, Any?> {
190
+ val issues = a11yChecker.getNodesToCheck(currentDecorView!!)
191
+ val nodesWithStringKeys = issues.mapKeys { it.key.toString() }.mapValues { it.value }
192
+
193
+ if (send) {
194
+ sendEvent("onAmaNodes", nodesWithStringKeys)
195
+ }
196
+
197
+ return nodesWithStringKeys
198
+ }
199
+
200
+ /** Finds the deepest child view at a given screen coordinate. */
201
+ private fun findViewAt(root: View, x: Int, y: Int): View? {
202
+ val viewsToVisit = arrayListOf<View>()
203
+ viewsToVisit.add(root)
204
+
205
+ var bestFit: View? = null
206
+ val hitRect = Rect()
207
+
208
+ var i = 0
209
+ while (i < viewsToVisit.size) {
210
+ val child = viewsToVisit[i++]
211
+
212
+ child.getGlobalVisibleRect(hitRect)
213
+
214
+ if (hitRect.contains(x, y)) {
215
+ bestFit = child
216
+
217
+ val info = child.createAccessibilityNodeInfo()
218
+ val a11yInfo = AccessibilityNodeInfoCompat.wrap(info)
219
+
220
+ // If is a button then we can stop there
221
+ if (child.isPressable(a11yInfo)) {
222
+ break
223
+ }
224
+
225
+ if (child is ViewGroup) {
226
+ for (j in 0 until child.childCount) {
227
+ viewsToVisit.add(child.getChildAt(j))
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ return bestFit
234
+ }
235
+
236
+ private fun runMyChecks(tappedView: View, rootView: View) {
237
+ val beforeSnapshot = takeSnapshotOfTappedView(tappedView, rootView)
238
+ val beforeModalVisible = isModalVisible(rootView)
239
+
240
+ Handler(Looper.getMainLooper())
241
+ .postDelayed(
242
+ {
243
+ if (tappedView.isAttachedToWindow && rootView.isAttachedToWindow) {
244
+ val afterSnapshot = takeSnapshotOfTappedView(tappedView, rootView)
245
+ val afterModalVisible = isModalVisible(rootView)
246
+
247
+ getNodesToCheck()
248
+
249
+ Handler(Looper.getMainLooper())
250
+ .postDelayed(
251
+ {
252
+ if (tappedView.isAttachedToWindow &&
253
+ rootView.isAttachedToWindow
254
+ ) {
255
+ val afterSettledSnapshot =
256
+ takeSnapshotOfTappedView(
257
+ tappedView,
258
+ rootView
259
+ )
260
+
261
+ val event =
262
+ Bundle().apply {
263
+ putInt("rootTag", tappedView.id)
264
+ putBundle(
265
+ "before",
266
+ convertSnapshotMapToBundle(
267
+ beforeSnapshot
268
+ )
269
+ )
270
+ putBundle(
271
+ "after",
272
+ convertSnapshotMapToBundle(
273
+ afterSnapshot
274
+ )
275
+ )
276
+ putBundle(
277
+ "afterSettled",
278
+ convertSnapshotMapToBundle(
279
+ afterSettledSnapshot
280
+ )
281
+ )
282
+ putBoolean(
283
+ "beforeModalVisible",
284
+ beforeModalVisible
285
+ )
286
+ putBoolean(
287
+ "afterModalVisible",
288
+ afterModalVisible
289
+ )
290
+ }
291
+
292
+ sendEvent("onUIInteraction", event)
293
+ }
294
+ },
295
+ Constants.uiCheckDelay
296
+ )
297
+ }
298
+ },
299
+ 60
300
+ )
301
+ }
302
+
303
+ private fun isModalVisible(rootView: View): Boolean {
304
+ return findModalView(rootView) != null
305
+ }
306
+
307
+ private fun findModalView(view: View): View? {
308
+ val className = view.javaClass.name
309
+ if (className.contains("ReactModalHostView") || className.contains("Dialog")) {
310
+ return view
311
+ }
312
+
313
+ if (view is ViewGroup) {
314
+ for (i in 0 until view.childCount) {
315
+ val modal = findModalView(view.getChildAt(i))
316
+ if (modal != null) {
317
+ return modal
318
+ }
319
+ }
320
+ }
321
+
322
+ return null
323
+ }
324
+ }
325
+
326
+ data class Snapshot(
327
+ val fgColor: String?,
328
+ val bgColor: String?,
329
+ val x: Int,
330
+ val y: Int,
331
+ val width: Int,
332
+ val height: Int,
333
+ val parentId: Int,
334
+ val isPressable: Boolean,
335
+ val isChecked: Boolean,
336
+ val isBusy: Boolean,
337
+ val isSelected: Boolean,
338
+ val isDisabled: Boolean,
339
+ val isExpanded: Boolean
340
+ )
341
+
342
+ private fun takeSnapshotOfTappedView(
343
+ view: View,
344
+ root: View,
345
+ snapshots: MutableMap<Int, Snapshot> = mutableMapOf()
346
+ ): MutableMap<Int, Snapshot> {
347
+ val info = view.createAccessibilityNodeInfo()
348
+ val a11yInfo = AccessibilityNodeInfoCompat.wrap(info)
349
+
350
+ val position = view.getGlobalDpBounds(root)
351
+ val a11yStates = view.a11yStates()
352
+
353
+ snapshots[view.id] =
354
+ Snapshot(
355
+ fgColor = view.getTextColorHex(),
356
+ bgColor = view.getBackgroundColorHex(),
357
+ x = position[0],
358
+ y = position[1],
359
+ width = position[2],
360
+ height = position[3],
361
+ parentId = (view.parent as? View)?.id ?: View.NO_ID,
362
+ isPressable = view.isPressable(a11yInfo),
363
+ isChecked = a11yInfo.isChecked,
364
+ isBusy = view.isBusy(),
365
+ isDisabled = a11yStates.isDisabled,
366
+ isExpanded = a11yStates.isExpanded,
367
+ isSelected = a11yStates.isSelected,
368
+ )
369
+
370
+ if (view is ViewGroup) {
371
+ for (i in 0 until view.childCount) {
372
+ takeSnapshotOfTappedView(view.getChildAt(i), root, snapshots)
373
+ }
374
+ }
375
+
376
+ return snapshots
377
+ }
378
+
379
+ fun View.getGlobalDpBounds(rootView: View): List<Int> {
380
+ val abs = Rect().also { createAccessibilityNodeInfo().getBoundsInScreen(it) }
381
+
382
+ val origin = IntArray(2).also { rootView.getLocationOnScreen(it) }
383
+ val relLeftPx = abs.left - origin[0]
384
+ val relTopPx = abs.top - origin[1]
385
+ val widthPx = abs.width()
386
+ val heightPx = abs.height()
387
+
388
+ val metrics: DisplayMetrics = resources.displayMetrics
389
+ val d = metrics.density
390
+ val leftDp = (relLeftPx / d).toInt()
391
+ val topDp = (relTopPx / d).toInt()
392
+ val widthDp = (widthPx / d).toInt()
393
+ val heightDp = (heightPx / d).toInt()
394
+
395
+ return listOf(leftDp, topDp, widthDp, heightDp)
396
+ }
397
+
398
+ private fun convertSnapshotMapToBundle(snapshotMap: Map<Int, Snapshot>): Bundle {
399
+ return Bundle().apply {
400
+ snapshotMap.forEach { (key, snapshot) ->
401
+ putBundle(key.toString(), convertSnapshotToBundle(snapshot))
402
+ }
403
+ }
404
+ }
405
+
406
+ private fun convertSnapshotToBundle(snapshot: Snapshot): Bundle {
407
+ return Bundle().apply {
408
+ snapshot.fgColor?.let { putString("fgColor", it) }
409
+ snapshot.bgColor?.let { putString("bgColor", it) }
410
+
411
+ putInt("x", snapshot.x)
412
+ putInt("y", snapshot.y)
413
+ putInt("width", snapshot.width)
414
+ putInt("height", snapshot.height)
415
+ putInt("parentId", snapshot.parentId)
416
+
417
+ putBoolean("isPressable", snapshot.isPressable)
418
+ putBoolean("isChecked", snapshot.isChecked)
419
+ putBoolean("isBusy", snapshot.isBusy)
420
+ putBoolean("isExpanded", snapshot.isExpanded)
421
+ putBoolean("isDisabled", snapshot.isDisabled)
422
+ putBoolean("isSelected", snapshot.isSelected)
423
+ }
424
+ }
425
+
426
+ fun View.isBusy(): Boolean {
427
+ val info = AccessibilityNodeInfoCompat.wrap(this.createAccessibilityNodeInfo())
428
+
429
+ val combined = buildString {
430
+ info.contentDescription?.let { append(it).append(' ') }
431
+ info.text?.let { append(it) }
432
+ }
433
+
434
+ if (matchesBusyToken(this, combined)) {
435
+ return true
436
+ }
437
+
438
+ return false
439
+ }
440
+
441
+ data class A11yStates(val isExpanded: Boolean, val isDisabled: Boolean, val isSelected: Boolean)
442
+
443
+ fun View.a11yStates(): A11yStates {
444
+ val info = AccessibilityNodeInfoCompat.wrap(createAccessibilityNodeInfo())
445
+
446
+ val isDisabled = !info.isEnabled || !isEnabled || !isActuallyInteractable()
447
+ val isSelected = info.isSelected || isSelected || (this as? Checkable)?.isChecked == true
448
+
449
+ val isExpanded: Boolean? =
450
+ when {
451
+ hasAction(
452
+ info,
453
+ AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COLLAPSE
454
+ ) &&
455
+ !hasAction(
456
+ info,
457
+ AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_EXPAND
458
+ ) -> true
459
+ hasAction(
460
+ info,
461
+ AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_EXPAND
462
+ ) &&
463
+ !hasAction(
464
+ info,
465
+ AccessibilityNodeInfoCompat.AccessibilityActionCompat
466
+ .ACTION_COLLAPSE
467
+ ) -> false
468
+ else -> {
469
+ val haystack =
470
+ buildString {
471
+ info.contentDescription?.let { append(it).append(' ') }
472
+ info.text?.let { append(it).append(' ') }
473
+ info.stateDescription?.let { append(it) }
474
+ ViewCompat.getStateDescription(this@a11yStates)?.let {
475
+ append(' ').append(it)
476
+ }
477
+ }
478
+ .trim()
479
+ parseExpandedFromText(haystack, context)
480
+ }
481
+ }
482
+
483
+ return A11yStates(isExpanded ?: false, isDisabled, isSelected)
484
+ }
485
+
486
+ private fun hasAction(
487
+ info: AccessibilityNodeInfoCompat,
488
+ action: AccessibilityNodeInfoCompat.AccessibilityActionCompat
489
+ ): Boolean = info.actionList.any { it.id == action.id }
490
+
491
+ /** Treat hidden/transparent/non-interactive as effectively disabled for interaction checks. */
492
+ private fun View.isActuallyInteractable(): Boolean =
493
+ visibility == View.VISIBLE && alpha > 0.01f && isClickableOrFocusable()
494
+
495
+ private fun View.isClickableOrFocusable(): Boolean = isClickable || isLongClickable || isFocusable
496
+
497
+ private fun parseExpandedFromText(s: String, ctx: Context): Boolean? {
498
+ if (s.isBlank()) return null
499
+ val expandedTokens = rnTokens(ctx, "state_expanded_description", listOf("expanded"))
500
+ val collapsedTokens = rnTokens(ctx, "state_collapsed_description", listOf("collapsed"))
501
+ val low = s.lowercase()
502
+ return when {
503
+ expandedTokens.any { low.contains(it) } -> true
504
+ collapsedTokens.any { low.contains(it) } -> false
505
+ else -> null
506
+ }
507
+ }
508
+
509
+ private fun rnTokens(ctx: Context, key: String, fallbacks: List<String>): List<String> {
510
+ val res = ctx.resources
511
+ val out = mutableListOf<String>()
512
+
513
+ fun addIfExists(pkg: String) {
514
+ val id = res.getIdentifier(key, "string", pkg)
515
+ if (id != 0) out += res.getString(id).lowercase()
516
+ }
517
+
518
+ addIfExists(ctx.packageName) // app bundle
519
+ addIfExists("com.facebook.react") // RN bundle (older merges)
520
+ out += fallbacks.map { it.lowercase() }
521
+
522
+ return out.distinct()
523
+ }
524
+
525
+ private fun matchesBusyToken(view: View, s: String): Boolean {
526
+ busyStrings(view).forEach { token ->
527
+ if (token.isNotEmpty() && s.contains(token, ignoreCase = true)) return true
528
+ }
529
+
530
+ return s.contains("busy", ignoreCase = true) || s.contains("loading", ignoreCase = true)
531
+ }
532
+
533
+ private fun busyStrings(view: View): List<String> {
534
+ val res = view.resources
535
+ val pkg = view.context.packageName
536
+ return listOfNotNull(
537
+ res.getIdentifier("state_busy_description", "string", pkg)
538
+ .takeIf { it != 0 }
539
+ ?.let(res::getString),
540
+ res.getIdentifier("state_busy_description", "string", "com.facebook.react")
541
+ .takeIf { it != 0 }
542
+ ?.let(res::getString)
543
+ )
544
+ }
@@ -0,0 +1,30 @@
1
+ package expo.modules.ama
2
+
3
+ import android.content.Context
4
+ import android.webkit.WebView
5
+ import android.webkit.WebViewClient
6
+ import expo.modules.kotlin.AppContext
7
+ import expo.modules.kotlin.viewevent.EventDispatcher
8
+ import expo.modules.kotlin.views.ExpoView
9
+
10
+ class ReactNativeAmaView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
11
+ // Creates and initializes an event dispatcher for the `onLoad` event.
12
+ // The name of the event is inferred from the value and needs to match the event name defined in the module.
13
+ private val onLoad by EventDispatcher()
14
+
15
+ // Defines a WebView that will be used as the root subview.
16
+ internal val webView = WebView(context).apply {
17
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
18
+ webViewClient = object : WebViewClient() {
19
+ override fun onPageFinished(view: WebView, url: String) {
20
+ // Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
21
+ onLoad(mapOf("url" to url))
22
+ }
23
+ }
24
+ }
25
+
26
+ init {
27
+ // Adds the WebView to the view hierarchy.
28
+ addView(webView)
29
+ }
30
+ }