@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,29 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ReactNativeAma'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = {
14
+ :ios => '15.1',
15
+ :tvos => '15.1'
16
+ }
17
+ s.swift_version = '5.4'
18
+ s.source = { git: 'https://github.com/FormidableLabs/react-native-ama/' }
19
+ s.static_framework = true
20
+
21
+ s.dependency 'ExpoModulesCore'
22
+
23
+ # Swift/Objective-C compatibility
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ }
27
+
28
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
29
+ end
@@ -0,0 +1,568 @@
1
+ #if DEBUG
2
+ import ExpoModulesCore
3
+ import UIKit
4
+
5
+ struct Constants {
6
+ static let debounce: TimeInterval = 2.0
7
+ }
8
+
9
+ public class ReactNativeAmaModule: Module {
10
+ private var isMonitoring = false
11
+ private var currentDecorView: UIView?
12
+ private var displayLink: CADisplayLink?
13
+
14
+ private var a11yChecker: NodesGrabber?
15
+ private var highlighter: Highlight?
16
+ private var isCheckScheduled = false
17
+
18
+ /**
19
+ * We need to wait for the navigation transition to complete!
20
+ */
21
+ private var uiCheckDelay = 1000
22
+
23
+ private var windowTapRecognizer: WindowTapGestureRecognizer?
24
+ private var tapDelegate: AmaTapGestureDelegate?
25
+ private var gap: CGFloat = 0
26
+ private var borderWidth: CGFloat = 3
27
+
28
+ public func definition() -> ModuleDefinition {
29
+ Name("ReactNativeAma")
30
+
31
+ Events("onAmaNodes", "onUIInteraction")
32
+
33
+ Function("start") { (options: [String: Any]?) in
34
+ let uiCheck = options?["ui"] as? Bool ?? false
35
+ uiCheckDelay = options?["delay"] as? Int ?? uiCheckDelay
36
+ gap = options?["gap"] as? CGFloat ?? gap
37
+ borderWidth = options?["borderWidth"] as? CGFloat ?? borderWidth
38
+
39
+ guard !isMonitoring else { return }
40
+
41
+ Logger.info("start", "👀 Start Monitoring 👀")
42
+
43
+ self.a11yChecker = NodesGrabber(appContext: self.appContext!)
44
+ self.highlighter = Highlight()
45
+
46
+ isMonitoring = true
47
+
48
+ DispatchQueue.main.async {
49
+ guard
50
+ let viewController = self.appContext?.utilities?
51
+ .currentViewController(),
52
+ let decorView = viewController.view,
53
+ let currentView = viewController.view,
54
+ let window = currentView.window
55
+ else {
56
+ Logger.info("start", "DEBUG guard failed — no viewController/decorView/window yet")
57
+ return
58
+ }
59
+
60
+ self.currentDecorView = decorView
61
+ self.setupDisplayLink()
62
+
63
+ if uiCheck {
64
+ self.attachWindowTapProbe()
65
+ }
66
+ }
67
+ }
68
+
69
+ Function("stop") {
70
+ guard isMonitoring else { return }
71
+
72
+ if let recognizer = self.windowTapRecognizer,
73
+ let window = UIApplication.shared.currentKeyWindow
74
+ {
75
+ window.removeGestureRecognizer(recognizer)
76
+ }
77
+
78
+ self.windowTapRecognizer = nil
79
+ self.isMonitoring = false
80
+
81
+ DispatchQueue.main.async {
82
+ self.displayLink?.invalidate()
83
+ self.displayLink = nil
84
+ }
85
+ }
86
+
87
+ AsyncFunction("highlight") {
88
+ (viewId: Int, mode: String, hexColor: String, issueCount: Int) async -> [Double]? in
89
+ guard
90
+ let root = self.currentDecorView,
91
+ let target = await root.viewWithTag(viewId)
92
+ else {
93
+ return nil
94
+ }
95
+
96
+ await MainActor.run {
97
+ if let scroll = target.enclosingScrollView {
98
+ var frameInScroll = target.convert(
99
+ target.bounds,
100
+ to: scroll
101
+ )
102
+ let m = CGFloat(10)
103
+
104
+ frameInScroll.origin.y = max(0, frameInScroll.origin.y - m)
105
+ scroll.scrollRectToVisible(frameInScroll, animated: false)
106
+ }
107
+
108
+ self.highlighter?.highlight(
109
+ view: target,
110
+ mode: mode,
111
+ hexColor: hexColor,
112
+ gap: gap ?? 0,
113
+ lineWidth: borderWidth ?? 3,
114
+ issueCount: issueCount
115
+ )
116
+ }
117
+
118
+ let bounds: CGRect = await MainActor.run {
119
+ target.convert(target.bounds, to: nil)
120
+ }
121
+
122
+ return [
123
+ bounds.origin.x,
124
+ bounds.origin.y,
125
+ bounds.width,
126
+ bounds.height,
127
+ ]
128
+ }
129
+
130
+ AsyncFunction("clearHighlight") { (viewId: Int) in
131
+ await MainActor.run {
132
+ self.highlighter?.clearHighlight(viewId: viewId)
133
+ }
134
+ }
135
+ }
136
+
137
+ private func setupDisplayLink() {
138
+ displayLink = CADisplayLink(
139
+ target: self,
140
+ selector: #selector(scheduleA11yCheck)
141
+ )
142
+ displayLink?.add(to: .main, forMode: .default)
143
+ }
144
+
145
+ @objc private func scheduleA11yCheck() {
146
+ guard isMonitoring, !isCheckScheduled else { return }
147
+
148
+ isCheckScheduled = true
149
+
150
+ DispatchQueue.main.asyncAfter(deadline: .now() + Constants.debounce) {
151
+ [weak self] in
152
+ guard let self = self, self.currentDecorView != nil else {
153
+ self?.isCheckScheduled = false
154
+
155
+ return
156
+ }
157
+
158
+ self.displayLink?.isPaused = true
159
+
160
+ // Clean up any orphaned highlights (for unmounted views)
161
+ self.highlighter?.cleanupOrphanedHighlights()
162
+
163
+ self.getNodesToCheck()
164
+
165
+ DispatchQueue.main.async {
166
+ self.displayLink?.isPaused = false
167
+ self.isCheckScheduled = false
168
+ }
169
+ }
170
+ }
171
+
172
+ private func getNodesToCheck() {
173
+ guard isMonitoring else { return }
174
+ guard let result = a11yChecker?.getNodesToCheck(on: currentDecorView)
175
+ else { return }
176
+
177
+ if let shouldSend = result.send as? Bool, shouldSend {
178
+ // Since result.nodes is not optional, we can access it directly here.
179
+ let nodes = result.nodes
180
+ let nodesWithStringKeys = Dictionary(
181
+ uniqueKeysWithValues: nodes.map { (key, value) in
182
+ (String(key), value.toDictionary())
183
+ }
184
+ )
185
+
186
+ sendEvent(
187
+ "onAmaNodes",
188
+ nodesWithStringKeys
189
+ )
190
+ }
191
+ }
192
+ }
193
+
194
+ extension UIView {
195
+ fileprivate var enclosingScrollView: UIScrollView? {
196
+ var v: UIView? = self
197
+ while let view = v {
198
+ if let scroll = view as? UIScrollView {
199
+ return scroll
200
+ }
201
+ v = view.superview
202
+ }
203
+ return nil
204
+ }
205
+
206
+ func getGlobalBounds(relativeTo rootView: UIView) -> CGRect {
207
+ return self.convert(self.bounds, to: rootView)
208
+ }
209
+
210
+ func isChecked() -> Bool {
211
+ if let control = self as? UIControl {
212
+ if let uiSwitch = control as? UISwitch {
213
+ return uiSwitch.isOn
214
+ }
215
+
216
+ return control.isSelected
217
+ }
218
+
219
+ return self.accessibilityTraits.contains(.selected)
220
+ }
221
+
222
+ func isModal() -> Bool {
223
+ let names = [
224
+ "RCTModalHostView",
225
+ "RCTModalHostViewComponentView",
226
+ ]
227
+ for name in names {
228
+ if let cls = NSClassFromString(name), self.isKind(of: cls) {
229
+ return true
230
+ }
231
+ }
232
+
233
+ return false
234
+ }
235
+ }
236
+
237
+ extension UIApplication {
238
+ var currentKeyWindow: UIWindow? {
239
+ connectedScenes
240
+ .compactMap { $0 as? UIWindowScene }
241
+ .flatMap { $0.windows }
242
+ .first { $0.isKeyWindow }
243
+ }
244
+ }
245
+
246
+ class AmaTapGestureDelegate: NSObject, UIGestureRecognizerDelegate {
247
+ weak var module: ReactNativeAmaModule?
248
+
249
+ init(module: ReactNativeAmaModule) {
250
+ self.module = module
251
+ }
252
+
253
+ func gestureRecognizer(
254
+ _ gestureRecognizer: UIGestureRecognizer,
255
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer:
256
+ UIGestureRecognizer
257
+ )
258
+ -> Bool
259
+ {
260
+ return true
261
+ }
262
+ }
263
+
264
+ extension ReactNativeAmaModule {
265
+ func attachWindowTapProbe() {
266
+ guard let window = UIApplication.shared.currentKeyWindow else { return }
267
+ if windowTapRecognizer != nil { return }
268
+
269
+ let delegate = AmaTapGestureDelegate(module: self)
270
+ tapDelegate = delegate // keep it alive
271
+
272
+ let recognizer = WindowTapGestureRecognizer(
273
+ target: self,
274
+ action: #selector(handleWindowTap(_:))
275
+ )
276
+ recognizer.cancelsTouchesInView = false
277
+ recognizer.delaysTouchesBegan = false
278
+ recognizer.delaysTouchesEnded = false
279
+ recognizer.delegate = delegate
280
+
281
+ window.addGestureRecognizer(recognizer)
282
+ windowTapRecognizer = recognizer
283
+ }
284
+
285
+ public func gestureRecognizer(
286
+ _ gestureRecognizer: UIGestureRecognizer,
287
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer:
288
+ UIGestureRecognizer
289
+ ) -> Bool {
290
+ return true
291
+ }
292
+ }
293
+
294
+ extension ReactNativeAmaModule {
295
+ private func findPressableAncestor(
296
+ from view: UIView,
297
+ root: UIView? = nil,
298
+ maxLevels: Int = 8
299
+ ) -> UIView? {
300
+ var current: UIView? = view
301
+ var level = 0
302
+
303
+ while let v = current, level <= maxLevels {
304
+ if let root = root, v === root {
305
+ break
306
+ }
307
+
308
+ if v.isPressable {
309
+ return v
310
+ }
311
+
312
+ level += 1
313
+ current = v.superview
314
+ }
315
+
316
+ return nil
317
+ }
318
+
319
+ @objc
320
+ func handleWindowTap(_ recognizer: WindowTapGestureRecognizer) {
321
+ guard recognizer.state == .ended else { return }
322
+ guard let window = UIApplication.shared.currentKeyWindow else { return }
323
+ // Hit-test from the RN root view rather than the window: on-device the
324
+ // window's own hitTest stops at the SwiftUI `UIViewControllerWrapperView`
325
+ // bridge (e.g. Expo Router native tabs) and never recurses into the
326
+ // Fabric view tree mounted inside it. Starting from `currentDecorView`
327
+ // (the actual RN root, already resolved via appContext.utilities) skips
328
+ // that boundary entirely.
329
+ guard let decorView = currentDecorView else { return }
330
+
331
+ // Use the location captured eagerly in touchesEnded, not
332
+ // recognizer.location(in:) queried here — by the time this action
333
+ // fires, UIKit's touch bookkeeping for this recognizer can already be
334
+ // reset (observed as a stuck (0, 0) on device), since it shares the
335
+ // window with RN's own touch handler.
336
+ let windowLocation = recognizer.capturedLocation
337
+ let location = window.convert(windowLocation, to: decorView)
338
+
339
+ guard let hitView = decorView.hitTest(location, with: nil) else { return }
340
+
341
+ // Find the closest pressable ancestor
342
+ let pressable = findPressableAncestor(
343
+ from: hitView,
344
+ root: decorView,
345
+ maxLevels: 8
346
+ )
347
+
348
+ guard let targetView = pressable else { return }
349
+
350
+ runMyChecks(tappedView: targetView, rootView: decorView)
351
+ }
352
+ }
353
+
354
+ extension ReactNativeAmaModule {
355
+ private func runMyChecks(tappedView: UIView, rootView: UIView) {
356
+ let beforeSnapshot = takeSnapshotOfTappedView(
357
+ view: tappedView,
358
+ root: rootView
359
+ )
360
+ let beforeModalVisible = isModalVisible(in: rootView)
361
+
362
+ let delay = DispatchTime.now() + .milliseconds(60)
363
+ DispatchQueue.main.asyncAfter(deadline: delay) {
364
+ [weak self, weak tappedView, weak rootView] in
365
+ guard let self = self,
366
+ let tappedView = tappedView,
367
+ let rootView = rootView,
368
+ tappedView.window != nil,
369
+ rootView.window != nil
370
+ else {
371
+ return
372
+ }
373
+
374
+ let afterSnapshot = self.takeSnapshotOfTappedView(
375
+ view: tappedView,
376
+ root: rootView
377
+ )
378
+ let afterModalVisible = self.isModalVisible(in: rootView)
379
+
380
+ isCheckScheduled = true
381
+ self.getNodesToCheck()
382
+
383
+ let settleDelay = DispatchTime.now() + .milliseconds(Int(uiCheckDelay))
384
+ DispatchQueue.main.asyncAfter(deadline: settleDelay) {
385
+ [weak self, weak tappedView, weak rootView] in
386
+ guard let self = self else { return }
387
+ guard let tappedView = tappedView,
388
+ let rootView = rootView,
389
+ tappedView.window != nil,
390
+ rootView.window != nil
391
+ else {
392
+ isCheckScheduled = false
393
+ return
394
+ }
395
+
396
+ let settledSnapshot = self.takeSnapshotOfTappedView(
397
+ view: tappedView,
398
+ root: rootView
399
+ )
400
+
401
+ let payload: [String: Any] = [
402
+ "rootTag": tappedView.tag,
403
+ "before": self.convertSnapshotMapToDict(
404
+ snapshotMap: beforeSnapshot
405
+ ),
406
+ "after": self.convertSnapshotMapToDict(
407
+ snapshotMap: afterSnapshot
408
+ ),
409
+ "afterSettled": self.convertSnapshotMapToDict(
410
+ snapshotMap: settledSnapshot
411
+ ),
412
+ "beforeModalVisible": beforeModalVisible,
413
+ "afterModalVisible": afterModalVisible,
414
+ ]
415
+
416
+ isCheckScheduled = false
417
+ self.sendEvent("onUIInteraction", payload)
418
+ }
419
+ }
420
+ }
421
+
422
+ private func isModalVisible(in rootView: UIView) -> Bool {
423
+ return findModalView(in: rootView) != nil
424
+ }
425
+
426
+ private func findModalView(in view: UIView) -> UIView? {
427
+ if view.isModal() {
428
+ return view
429
+ }
430
+
431
+ for subview in view.subviews {
432
+ if let modal = findModalView(in: subview) {
433
+ return modal
434
+ }
435
+ }
436
+
437
+ return nil
438
+ }
439
+ }
440
+
441
+ struct Snapshot {
442
+ let fgColor: String?
443
+ let bgColor: String?
444
+ let x: Int
445
+ let y: Int
446
+ let width: Int
447
+ let height: Int
448
+ let parentId: Int
449
+ let isPressable: Bool
450
+ let isChecked: Bool
451
+ let isBusy: Bool
452
+ let isExpanded: Bool
453
+ let isDisabled: Bool
454
+ let isSelected: Bool
455
+ }
456
+
457
+ extension ReactNativeAmaModule {
458
+ func takeSnapshotOfTappedView(
459
+ view: UIView,
460
+ root: UIView,
461
+ snapshots: inout [Int: Snapshot]
462
+ ) {
463
+ let id = view.tag
464
+ let frame = view.convert(view.bounds, to: root)
465
+ let position = globalDpBounds(rect: frame, root: root)
466
+
467
+ let fgColor = view.contentColor?.hexString
468
+ let bgColor = view.contentBackgroundColor?.hexString
469
+
470
+ let a11yIsPressable = view.isPressable
471
+ let isChecked = view.accessibilityTraits.contains(.selected)
472
+ let isBusy = view.isBusy()
473
+ let a11yStates = view.a11yStates()
474
+
475
+ let parentId = (view.superview?.tag).map { $0 } ?? -1
476
+
477
+ snapshots[id] = Snapshot(
478
+ fgColor: fgColor,
479
+ bgColor: bgColor,
480
+ x: position[0],
481
+ y: position[1],
482
+ width: position[2],
483
+ height: position[3],
484
+ parentId: parentId,
485
+ isPressable: a11yIsPressable,
486
+ isChecked: isChecked,
487
+ isBusy: isBusy,
488
+ isExpanded: a11yStates.isExpanded,
489
+ isDisabled: a11yStates.isDisabled,
490
+ isSelected: a11yStates.isSelected,
491
+ )
492
+
493
+ if let group = view as? UIStackView {
494
+ for sub in group.arrangedSubviews {
495
+ takeSnapshotOfTappedView(
496
+ view: sub,
497
+ root: root,
498
+ snapshots: &snapshots
499
+ )
500
+ }
501
+ } else {
502
+ for sub in view.subviews {
503
+ takeSnapshotOfTappedView(
504
+ view: sub,
505
+ root: root,
506
+ snapshots: &snapshots
507
+ )
508
+ }
509
+ }
510
+ }
511
+
512
+ func takeSnapshotOfTappedView(view: UIView, root: UIView) -> [Int: Snapshot]
513
+ {
514
+ var map: [Int: Snapshot] = [:]
515
+ takeSnapshotOfTappedView(view: view, root: root, snapshots: &map)
516
+ return map
517
+ }
518
+
519
+ func globalDpBounds(rect: CGRect, root: UIView) -> [Int] {
520
+ let scale = UIScreen.main.scale
521
+ let leftDp = Int(rect.origin.x * scale / scale)
522
+ let topDp = Int(rect.origin.y * scale / scale)
523
+ let widthDp = Int(rect.size.width * scale / scale)
524
+ let heightDp = Int(rect.size.height * scale / scale)
525
+ return [leftDp, topDp, widthDp, heightDp]
526
+ }
527
+
528
+ func convertSnapshotMapToDict(snapshotMap: [Int: Snapshot]) -> [String: Any]
529
+ {
530
+ var dict: [String: Any] = [:]
531
+
532
+ for (key, snap) in snapshotMap {
533
+ dict["\(key)"] = convertSnapshotToDict(snapshot: snap)
534
+ }
535
+
536
+ return dict
537
+ }
538
+
539
+ func convertSnapshotToDict(snapshot: Snapshot) -> [String: Any] {
540
+ var result: [String: Any] = [:]
541
+
542
+ if let fg = snapshot.fgColor { result["fgColor"] = fg }
543
+ if let bg = snapshot.bgColor { result["bgColor"] = bg }
544
+
545
+ result["x"] = snapshot.x
546
+ result["y"] = snapshot.y
547
+ result["width"] = snapshot.width
548
+ result["height"] = snapshot.height
549
+ result["parentId"] = snapshot.parentId
550
+ result["isPressable"] = snapshot.isPressable
551
+ result["isChecked"] = snapshot.isChecked
552
+ result["isBusy"] = snapshot.isBusy
553
+ result["isDisabled"] = snapshot.isDisabled
554
+ result["isExpanded"] = snapshot.isExpanded
555
+ result["isSelected"] = snapshot.isSelected
556
+
557
+ return result
558
+ }
559
+ }
560
+ #else
561
+ import ExpoModulesCore
562
+
563
+ public class ReactNativeAmaModule: Module {
564
+ public func definition() -> ModuleDefinition {
565
+ Name("ReactNativeAma")
566
+ }
567
+ }
568
+ #endif
@@ -0,0 +1,38 @@
1
+ import ExpoModulesCore
2
+ import WebKit
3
+
4
+ // This view will be used as a native component. Make sure to inherit from `ExpoView`
5
+ // to apply the proper styling (e.g. border radius and shadows).
6
+ class ReactNativeAmaView: ExpoView {
7
+ let webView = WKWebView()
8
+ let onLoad = EventDispatcher()
9
+ var delegate: WebViewDelegate?
10
+
11
+ required init(appContext: AppContext? = nil) {
12
+ super.init(appContext: appContext)
13
+ clipsToBounds = true
14
+ delegate = WebViewDelegate { url in
15
+ self.onLoad(["url": url])
16
+ }
17
+ webView.navigationDelegate = delegate
18
+ addSubview(webView)
19
+ }
20
+
21
+ override func layoutSubviews() {
22
+ webView.frame = bounds
23
+ }
24
+ }
25
+
26
+ class WebViewDelegate: NSObject, WKNavigationDelegate {
27
+ let onUrlChange: (String) -> Void
28
+
29
+ init(onUrlChange: @escaping (String) -> Void) {
30
+ self.onUrlChange = onUrlChange
31
+ }
32
+
33
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {
34
+ if let url = webView.url {
35
+ onUrlChange(url.absoluteString)
36
+ }
37
+ }
38
+ }