@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
package/ama.config.json
ADDED
|
@@ -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,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
|
+
}
|