@rejourneyco/react-native 1.0.7 → 1.0.9

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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
  3. package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
  4. package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
  5. package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
  6. package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
  7. package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
  8. package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
  9. package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
  10. package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
  11. package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
  12. package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
  13. package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
  14. package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
  15. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
  16. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
  17. package/ios/Engine/DeviceRegistrar.swift +13 -3
  18. package/ios/Engine/RejourneyImpl.swift +202 -133
  19. package/ios/Recording/AnrSentinel.swift +58 -25
  20. package/ios/Recording/InteractionRecorder.swift +29 -0
  21. package/ios/Recording/RejourneyURLProtocol.swift +168 -0
  22. package/ios/Recording/ReplayOrchestrator.swift +241 -147
  23. package/ios/Recording/SegmentDispatcher.swift +155 -13
  24. package/ios/Recording/SpecialCases.swift +614 -0
  25. package/ios/Recording/StabilityMonitor.swift +42 -34
  26. package/ios/Recording/TelemetryPipeline.swift +38 -3
  27. package/ios/Recording/ViewHierarchyScanner.swift +1 -0
  28. package/ios/Recording/VisualCapture.swift +104 -28
  29. package/ios/Rejourney.mm +27 -8
  30. package/ios/Utility/ImageBlur.swift +0 -1
  31. package/lib/commonjs/index.js +32 -20
  32. package/lib/commonjs/sdk/autoTracking.js +162 -11
  33. package/lib/commonjs/sdk/constants.js +2 -2
  34. package/lib/commonjs/sdk/networkInterceptor.js +84 -4
  35. package/lib/commonjs/sdk/utils.js +1 -1
  36. package/lib/module/index.js +32 -20
  37. package/lib/module/sdk/autoTracking.js +162 -11
  38. package/lib/module/sdk/constants.js +2 -2
  39. package/lib/module/sdk/networkInterceptor.js +84 -4
  40. package/lib/module/sdk/utils.js +1 -1
  41. package/lib/typescript/NativeRejourney.d.ts +5 -2
  42. package/lib/typescript/sdk/autoTracking.d.ts +3 -1
  43. package/lib/typescript/sdk/constants.d.ts +2 -2
  44. package/lib/typescript/types/index.d.ts +15 -8
  45. package/package.json +4 -4
  46. package/src/NativeRejourney.ts +8 -5
  47. package/src/index.ts +46 -29
  48. package/src/sdk/autoTracking.ts +176 -11
  49. package/src/sdk/constants.ts +2 -2
  50. package/src/sdk/networkInterceptor.ts +110 -1
  51. package/src/sdk/utils.ts +1 -1
  52. package/src/types/index.ts +16 -9
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Copyright 2026 Rejourney
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import UIKit
18
+ import ObjectiveC
19
+
20
+ // MARK: - Detected map SDK type
21
+ enum MapSDKType {
22
+ case appleMapKit // MKMapView
23
+ case googleMaps // GMSMapView
24
+ case mapbox // MGLMapView
25
+ }
26
+
27
+ // MARK: - SpecialCases
28
+ /// Centralised detection and idle-state management for map views.
29
+ /// All map class names and SDK-specific hooks live here so the rest
30
+ /// of the recording pipeline only calls into this module.
31
+ ///
32
+ /// Safety: every call into a map SDK (delegate swizzle, property read)
33
+ /// is guarded by responds(to:), null checks, and do/catch. If any
34
+ /// hook fails we fall back to mapIdle = true so capture is never
35
+ /// permanently blocked. We never crash the host app.
36
+ @objc(SpecialCases)
37
+ public final class SpecialCases: NSObject {
38
+
39
+ @objc public static let shared = SpecialCases()
40
+
41
+ // MARK: - Public state
42
+
43
+ /// True when the current key window contains a supported map view.
44
+ @objc public private(set) var mapVisible: Bool = false
45
+
46
+ /// True when the map's camera has settled (no user gesture, no animation).
47
+ /// When mapVisible is false this value is meaningless.
48
+ /// Defaults to true so that if we fail to hook idle we still capture.
49
+ @objc public private(set) var mapIdle: Bool = true {
50
+ didSet {
51
+ if mapIdle && !oldValue && mapVisible {
52
+ // Map just settled — capture a frame immediately instead of
53
+ // waiting up to 1s for the next timer tick. This gives the
54
+ // replay an up-to-date frame the instant motion ends.
55
+ VisualCapture.shared.snapshotNow()
56
+ }
57
+ }
58
+ }
59
+
60
+ /// The detected SDK type, or nil if no map is present.
61
+ private(set) var detectedSDK: MapSDKType?
62
+
63
+ // MARK: - Internals
64
+
65
+ private var _hookedDelegateClass: AnyClass?
66
+ private var _hookedMapView: AnyObject?
67
+ private var _originalRegionDidChange: IMP?
68
+ private var _originalRegionWillChange: IMP?
69
+ private var _originalIdleAtCamera: IMP?
70
+ private var _originalWillMove: IMP?
71
+
72
+ /// When true, idle detection is driven by gesture recognizer observation
73
+ /// rather than SDK delegate callbacks. Used for Mapbox v10+/v11 whose
74
+ /// Swift closure-based event API cannot be hooked from the ObjC runtime.
75
+ private var _usesGestureBasedIdle = false
76
+
77
+ /// Debounce timer for gesture-based idle detection.
78
+ /// Fires after the last gesture end to account for momentum/deceleration.
79
+ /// Mapbox uses UIScrollView.DecelerationRate.normal (0.998/ms).
80
+ /// At 2s after a 500pt/s flick, residual velocity is ~9pt/s (barely visible).
81
+ private var _gestureDebounceTimer: Timer?
82
+ private static let _gestureDebounceDelay: TimeInterval = 2.0
83
+
84
+ /// Number of gesture recognizers currently in .began/.changed state.
85
+ private var _activeGestureCount = 0
86
+
87
+ /// Gesture recognizers we've added ourselves as targets to.
88
+ private var _observedGestureRecognizers: [UIGestureRecognizer] = []
89
+
90
+ private override init() {
91
+ super.init()
92
+ }
93
+
94
+ // MARK: - Map detection (shallow hierarchy walk)
95
+
96
+ /// One-time diagnostic scan counter for debug logging.
97
+ private var _diagScanCount = 0
98
+
99
+ /// Scan the key window for a known map view.
100
+ /// Call this from the capture timer (main thread, ~1 Hz).
101
+ /// Returns quickly on the first match; limited to depth 40.
102
+ @objc public func refreshMapState() {
103
+ guard Thread.isMainThread else {
104
+ DispatchQueue.main.async { [weak self] in self?.refreshMapState() }
105
+ return
106
+ }
107
+
108
+ guard let window = _keyWindow() else {
109
+ if _diagScanCount == 0 {
110
+ DiagnosticLog.trace("[SpecialCases] refreshMapState: no key window found")
111
+ }
112
+ _clearMapState()
113
+ return
114
+ }
115
+
116
+ _diagScanCount += 1
117
+
118
+ if _diagScanCount == 1 {
119
+ DiagnosticLog.trace("[SpecialCases] refreshMapState running (scan #1)")
120
+ }
121
+
122
+ if let (mapView, sdk) = _findMapView(in: window, depth: 0) {
123
+ let wasAlreadyVisible = mapVisible
124
+ mapVisible = true
125
+ detectedSDK = sdk
126
+
127
+ if !wasAlreadyVisible {
128
+ let className = NSStringFromClass(type(of: mapView))
129
+ DiagnosticLog.trace("[SpecialCases] Map DETECTED: class=\(className) sdk=\(sdk)")
130
+ }
131
+
132
+ // Only hook once per map view instance
133
+ if _hookedMapView == nil || _hookedMapView !== mapView {
134
+ _unhookPreviousDelegate()
135
+ _hookIdleCallbacks(mapView: mapView, sdk: sdk)
136
+ }
137
+
138
+ if !wasAlreadyVisible {
139
+ VisualCapture.shared.snapshotNow()
140
+ }
141
+ } else {
142
+ // Print diagnostic view tree dump on first 3 scans and every 10th
143
+ if _diagScanCount <= 3 || _diagScanCount % 10 == 0 {
144
+ _logViewTreeDiagnostic(window)
145
+ }
146
+ _clearMapState()
147
+ }
148
+ }
149
+
150
+ /// Log the first few levels of the view tree to help diagnose detection failures.
151
+ /// Debug-only (DiagnosticLog.trace).
152
+ private func _logViewTreeDiagnostic(_ window: UIView) {
153
+ var lines: [String] = ["[SpecialCases] scan #\(_diagScanCount) — no map found. Map-like classes:"]
154
+ var deepMatches: [String] = []
155
+ _findMapLikeClassNames(view: window, depth: 0, maxDepth: 40, matches: &deepMatches)
156
+ if deepMatches.isEmpty {
157
+ lines.append(" (none found in \(_countViews(window)) views)")
158
+ } else {
159
+ for match in deepMatches {
160
+ lines.append(" \(match)")
161
+ }
162
+ }
163
+ DiagnosticLog.trace(lines.joined(separator: "\n"))
164
+ }
165
+
166
+ /// Count total views in hierarchy (for diagnostic context).
167
+ private func _countViews(_ view: UIView) -> Int {
168
+ var count = 1
169
+ for sub in view.subviews { count += _countViews(sub) }
170
+ return count
171
+ }
172
+
173
+ private func _findMapLikeClassNames(view: UIView, depth: Int, maxDepth: Int, matches: inout [String]) {
174
+ guard depth <= maxDepth else { return }
175
+ let name = NSStringFromClass(type(of: view))
176
+ let nameLC = name.lowercased()
177
+ if nameLC.contains("map") || nameLC.contains("mbx") || nameLC.contains("mapbox") ||
178
+ nameLC.contains("metal") || nameLC.contains("opengl") {
179
+ matches.append("\(name) @depth=\(depth)")
180
+ }
181
+ for sub in view.subviews {
182
+ _findMapLikeClassNames(view: sub, depth: depth + 1, maxDepth: maxDepth, matches: &matches)
183
+ }
184
+ }
185
+
186
+ // MARK: - Map view search
187
+
188
+ // Expo Router + React Navigation nests navigators 3+ levels deep, each
189
+ // adding ~8 depth levels (UILayoutContainerView > UINavigationTransitionView
190
+ // > UIViewControllerWrapperView > RNSScreenView > RCTViewComponentView > …).
191
+ // In the test app the deepest RNSScreenView is already at depth 25 before
192
+ // the actual map view. 40 handles any reasonable nesting.
193
+ // The walk is cheap (~200 views, simple string checks) so 40 is safe at 1 Hz.
194
+ private static let _maxScanDepth = 40
195
+
196
+ private func _findMapView(in view: UIView, depth: Int) -> (UIView, MapSDKType)? {
197
+ guard depth < SpecialCases._maxScanDepth else { return nil }
198
+
199
+ // Walk the entire class inheritance chain — react-native-maps uses
200
+ // AIRMap (subclass of MKMapView), RCTMGLMapView (subclass of
201
+ // MGLMapView), etc. Checking only the runtime class misses these.
202
+ if let sdk = _classifyByInheritance(view) {
203
+ return (view, sdk)
204
+ }
205
+
206
+ for sub in view.subviews {
207
+ if let found = _findMapView(in: sub, depth: depth + 1) {
208
+ return found
209
+ }
210
+ }
211
+ return nil
212
+ }
213
+
214
+ /// Walk the superclass chain and return the map SDK type if any
215
+ /// ancestor is a known map base class.
216
+ ///
217
+ /// NSStringFromClass for Swift classes includes the module prefix, e.g.:
218
+ /// "MapboxMaps.MapView", "rnmapbox_maps.RNMBXMapView"
219
+ /// The module prefix varies by build config (static lib, framework, etc.)
220
+ /// so we use .contains() checks rather than strict prefix matching.
221
+ private func _classifyByInheritance(_ view: UIView) -> MapSDKType? {
222
+ var cls: AnyClass? = type(of: view)
223
+ while let c = cls {
224
+ let name = NSStringFromClass(c)
225
+
226
+ // Apple MapKit (ObjC class — no module prefix)
227
+ if name == "MKMapView" { return .appleMapKit }
228
+
229
+ // Google Maps iOS SDK (ObjC class)
230
+ if name == "GMSMapView" { return .googleMaps }
231
+
232
+ // Mapbox GL Native v5/v6 (ObjC class)
233
+ if name == "MGLMapView" { return .mapbox }
234
+
235
+ // Mapbox Maps SDK v10+/v11 (Swift class, used by @rnmapbox/maps)
236
+ // NSStringFromClass returns: "MapboxMaps.MapView"
237
+ // Use .contains to handle any module prefix variations.
238
+ if name.contains("MapboxMaps") && name.contains("MapView") { return .mapbox }
239
+
240
+ cls = class_getSuperclass(c)
241
+ }
242
+
243
+ // Also check the runtime class name directly for the RN wrapper.
244
+ // CocoaPods may compile it as "rnmapbox_maps.RNMBXMapView" or
245
+ // "RNMBX.RNMBXMapView" depending on the pod name.
246
+ let runtimeName = NSStringFromClass(type(of: view))
247
+ if runtimeName.contains("RNMBXMap") { return .mapbox }
248
+
249
+ return nil
250
+ }
251
+
252
+ // MARK: - Idle hooks (delegate swizzle, safe)
253
+
254
+ private func _hookIdleCallbacks(mapView: UIView, sdk: MapSDKType) {
255
+ _hookedMapView = mapView
256
+ // Reset idle to true (safe default) before attempting hook
257
+ mapIdle = true
258
+
259
+ switch sdk {
260
+ case .appleMapKit:
261
+ _hookAppleMapKit(mapView)
262
+ case .googleMaps:
263
+ _hookGoogleMaps(mapView)
264
+ case .mapbox:
265
+ _hookMapbox(mapView)
266
+ }
267
+ }
268
+
269
+ // ---- Apple MapKit ----
270
+ // MKMapViewDelegate: mapView(_:regionWillChangeAnimated:) -> not idle
271
+ // mapView(_:regionDidChangeAnimated:) -> idle
272
+ private func _hookAppleMapKit(_ mapView: UIView) {
273
+ guard mapView.responds(to: NSSelectorFromString("delegate")) else {
274
+ DiagnosticLog.trace("[SpecialCases] MKMapView has no delegate property")
275
+ return
276
+ }
277
+ guard let delegate = mapView.value(forKey: "delegate") as? NSObject else {
278
+ DiagnosticLog.trace("[SpecialCases] MKMapView delegate is nil")
279
+ return
280
+ }
281
+ _swizzleDelegateForAppleOrMapbox(delegate: delegate, isMapbox: false)
282
+ }
283
+
284
+ // ---- Google Maps ----
285
+ // GMSMapViewDelegate: mapView(_:willMove:) -> not idle
286
+ // mapView(_:idleAtCameraPosition:) -> idle
287
+ private func _hookGoogleMaps(_ mapView: UIView) {
288
+ guard mapView.responds(to: NSSelectorFromString("delegate")) else {
289
+ DiagnosticLog.trace("[SpecialCases] GMSMapView has no delegate property")
290
+ return
291
+ }
292
+ guard let delegate = mapView.value(forKey: "delegate") as? NSObject else {
293
+ DiagnosticLog.trace("[SpecialCases] GMSMapView delegate is nil")
294
+ return
295
+ }
296
+ _swizzleGoogleDelegate(delegate)
297
+ }
298
+
299
+ // ---- Mapbox ----
300
+ // Supports both old MGLMapView (v5/v6) and new MapboxMaps.MapView (v10+/v11).
301
+ private func _hookMapbox(_ mapView: UIView) {
302
+ // Old MGLMapView (v5/v6) — delegate-based, same pattern as Apple MapKit
303
+ if _superclassChainContains(mapView, name: "MGLMapView") {
304
+ guard mapView.responds(to: NSSelectorFromString("delegate")) else { return }
305
+ guard let delegate = mapView.value(forKey: "delegate") as? NSObject else { return }
306
+ _swizzleDelegateForAppleOrMapbox(delegate: delegate, isMapbox: true)
307
+ return
308
+ }
309
+
310
+ // @rnmapbox/maps v10+/v11 — the SDK's event API uses Swift generics
311
+ // and closures that can't be hooked from the ObjC runtime.
312
+ // Instead, we observe the map's UIGestureRecognizers directly.
313
+ // The MapboxMaps.MapView has pan/pinch/rotate/pitch recognizers
314
+ // exposed via its `gestures` GestureManager. These are standard
315
+ // UIGestureRecognizers added to the view hierarchy, so we can use
316
+ // addTarget(_:action:) without importing the framework.
317
+ _hookMapboxV10GestureRecognizers(mapView)
318
+ }
319
+
320
+ /// Check if any superclass has the given name.
321
+ private func _superclassChainContains(_ view: UIView, name: String) -> Bool {
322
+ var cls: AnyClass? = type(of: view)
323
+ while let c = cls {
324
+ if NSStringFromClass(c) == name { return true }
325
+ cls = class_getSuperclass(c)
326
+ }
327
+ return false
328
+ }
329
+
330
+ // MARK: - Mapbox v10+ gesture recognizer observation
331
+
332
+ /// Find the actual MapboxMaps.MapView and observe its gesture recognizers.
333
+ private func _hookMapboxV10GestureRecognizers(_ mapView: UIView) {
334
+ // The detected view might be the RNMBX wrapper. Find the actual
335
+ // MapboxMaps.MapView which holds the gesture recognizers.
336
+ let target = _findMapboxMapsView(in: mapView) ?? mapView
337
+ let targetClass = NSStringFromClass(type(of: target))
338
+ let mapViewClass = NSStringFromClass(type(of: mapView))
339
+ DiagnosticLog.trace("[SpecialCases] Mapbox v10+ hook: detected=\(mapViewClass), target=\(targetClass)")
340
+
341
+ // Collect all gesture recognizers on the map view.
342
+ // The MapboxMaps.MapView has pan, pinch, rotate, pitch, double-tap,
343
+ // quick-zoom, and single-tap recognizers.
344
+ guard let recognizers = target.gestureRecognizers, !recognizers.isEmpty else {
345
+ DiagnosticLog.trace("[SpecialCases] Mapbox v10+: no gesture recognizers on \(NSStringFromClass(type(of: target))), falling back to touch-based")
346
+ _usesGestureBasedIdle = true
347
+ return
348
+ }
349
+
350
+ // Only observe continuous gestures that produce map motion
351
+ // (pan, pinch, rotate, pitch — typically UIPanGestureRecognizer,
352
+ // UIPinchGestureRecognizer, UIRotationGestureRecognizer, and
353
+ // Mapbox's custom pitch handler which is also a pan recognizer).
354
+ for gr in recognizers {
355
+ if gr is UIPanGestureRecognizer ||
356
+ gr is UIPinchGestureRecognizer ||
357
+ gr is UIRotationGestureRecognizer {
358
+ gr.addTarget(self, action: #selector(_handleMapGesture(_:)))
359
+ _observedGestureRecognizers.append(gr)
360
+ }
361
+ }
362
+
363
+ if _observedGestureRecognizers.isEmpty {
364
+ DiagnosticLog.trace("[SpecialCases] Mapbox v10+: no continuous gesture recognizers found, falling back to touch-based")
365
+ _usesGestureBasedIdle = true
366
+ return
367
+ }
368
+
369
+ _usesGestureBasedIdle = true
370
+ DiagnosticLog.trace("[SpecialCases] Mapbox v10+: observing \(_observedGestureRecognizers.count) gesture recognizers")
371
+ }
372
+
373
+ /// Find the actual MapboxMaps.MapView in a view and its near children.
374
+ /// Uses .contains() for class name matching to handle module prefix variations.
375
+ private func _findMapboxMapsView(in view: UIView) -> UIView? {
376
+ if _isMapboxMapsViewClass(view) { return view }
377
+ for sub in view.subviews {
378
+ if _isMapboxMapsViewClass(sub) { return sub }
379
+ }
380
+ for sub in view.subviews {
381
+ for subsub in sub.subviews {
382
+ if _isMapboxMapsViewClass(subsub) { return subsub }
383
+ }
384
+ }
385
+ // Go one more level — some wrappers add intermediate containers
386
+ for sub in view.subviews {
387
+ for subsub in sub.subviews {
388
+ for subsubsub in subsub.subviews {
389
+ if _isMapboxMapsViewClass(subsubsub) { return subsubsub }
390
+ }
391
+ }
392
+ }
393
+ return nil
394
+ }
395
+
396
+ /// Check if a view is the actual MapboxMaps.MapView (not the RN wrapper).
397
+ private func _isMapboxMapsViewClass(_ view: UIView) -> Bool {
398
+ let name = NSStringFromClass(type(of: view))
399
+ return name.contains("MapboxMaps") && name.contains("MapView")
400
+ }
401
+
402
+ /// Target-action handler for map gesture recognizers.
403
+ @objc private func _handleMapGesture(_ gr: UIGestureRecognizer) {
404
+ switch gr.state {
405
+ case .began:
406
+ _activeGestureCount += 1
407
+ _gestureDebounceTimer?.invalidate()
408
+ _gestureDebounceTimer = nil
409
+ if mapIdle {
410
+ mapIdle = false
411
+ }
412
+
413
+ case .ended, .cancelled, .failed:
414
+ _activeGestureCount = max(0, _activeGestureCount - 1)
415
+ if _activeGestureCount == 0 {
416
+ // All gestures ended — start the deceleration debounce timer.
417
+ _gestureDebounceTimer?.invalidate()
418
+ _gestureDebounceTimer = Timer.scheduledTimer(
419
+ withTimeInterval: SpecialCases._gestureDebounceDelay,
420
+ repeats: false
421
+ ) { [weak self] _ in
422
+ guard let self = self else { return }
423
+ self._gestureDebounceTimer = nil
424
+ if !self.mapIdle {
425
+ self.mapIdle = true
426
+ }
427
+ }
428
+ }
429
+
430
+ default:
431
+ break
432
+ }
433
+ }
434
+
435
+ // MARK: - Touch-based idle detection (fallback for when gesture observation fails)
436
+
437
+ /// Called by InteractionRecorder when a touch begins while a map is visible.
438
+ @objc public func notifyTouchBegan() {
439
+ guard _usesGestureBasedIdle, _observedGestureRecognizers.isEmpty, mapVisible else { return }
440
+ _gestureDebounceTimer?.invalidate()
441
+ _gestureDebounceTimer = nil
442
+ if mapIdle {
443
+ mapIdle = false
444
+ }
445
+ }
446
+
447
+ /// Called by InteractionRecorder when a touch ends/cancels while a map is visible.
448
+ @objc public func notifyTouchEnded() {
449
+ guard _usesGestureBasedIdle, _observedGestureRecognizers.isEmpty, mapVisible else { return }
450
+ _gestureDebounceTimer?.invalidate()
451
+ _gestureDebounceTimer = Timer.scheduledTimer(
452
+ withTimeInterval: SpecialCases._gestureDebounceDelay,
453
+ repeats: false
454
+ ) { [weak self] _ in
455
+ guard let self = self else { return }
456
+ self._gestureDebounceTimer = nil
457
+ if !self.mapIdle {
458
+ self.mapIdle = true
459
+ }
460
+ }
461
+ }
462
+
463
+ // MARK: - Apple / Mapbox delegate swizzle
464
+
465
+ /// Both Apple MapKit and Mapbox use `regionDidChangeAnimated:` /
466
+ /// `regionWillChangeAnimated:` on their delegate protocols.
467
+ /// The ObjC selectors are identical:
468
+ /// mapView:regionDidChangeAnimated:
469
+ /// mapView:regionWillChangeAnimated:
470
+ private func _swizzleDelegateForAppleOrMapbox(delegate: NSObject, isMapbox: Bool) {
471
+ let delegateClass: AnyClass = type(of: delegate)
472
+
473
+ // regionDidChangeAnimated -> idle
474
+ let didChangeSel = NSSelectorFromString("mapView:regionDidChangeAnimated:")
475
+ if let original = class_getInstanceMethod(delegateClass, didChangeSel) {
476
+ let originalIMP = method_getImplementation(original)
477
+ _originalRegionDidChange = originalIMP
478
+ _hookedDelegateClass = delegateClass
479
+
480
+ let block: @convention(block) (AnyObject, AnyObject, Bool) -> Void = { [weak self] obj, mapView, animated in
481
+ // Set idle FIRST, then call original
482
+ self?.mapIdle = true
483
+ // Call original IMP safely
484
+ typealias FnType = @convention(c) (AnyObject, Selector, AnyObject, Bool) -> Void
485
+ let fn = unsafeBitCast(originalIMP, to: FnType.self)
486
+ fn(obj, didChangeSel, mapView, animated)
487
+ }
488
+ let newIMP = imp_implementationWithBlock(block)
489
+ method_setImplementation(original, newIMP)
490
+ }
491
+
492
+ // regionWillChangeAnimated -> not idle
493
+ let willChangeSel = NSSelectorFromString("mapView:regionWillChangeAnimated:")
494
+ if let original = class_getInstanceMethod(delegateClass, willChangeSel) {
495
+ let originalIMP = method_getImplementation(original)
496
+ _originalRegionWillChange = originalIMP
497
+
498
+ let block: @convention(block) (AnyObject, AnyObject, Bool) -> Void = { [weak self] obj, mapView, animated in
499
+ self?.mapIdle = false
500
+ typealias FnType = @convention(c) (AnyObject, Selector, AnyObject, Bool) -> Void
501
+ let fn = unsafeBitCast(originalIMP, to: FnType.self)
502
+ fn(obj, willChangeSel, mapView, animated)
503
+ }
504
+ let newIMP = imp_implementationWithBlock(block)
505
+ method_setImplementation(original, newIMP)
506
+ }
507
+
508
+ DiagnosticLog.trace("[SpecialCases] Hooked \(isMapbox ? "Mapbox" : "Apple") delegate on \(delegateClass)")
509
+ }
510
+
511
+ // MARK: - Google Maps delegate swizzle
512
+
513
+ /// Google Maps uses `mapView:idleAtCameraPosition:` and `mapView:willMove:`.
514
+ private func _swizzleGoogleDelegate(_ delegate: NSObject) {
515
+ let delegateClass: AnyClass = type(of: delegate)
516
+
517
+ // idleAtCameraPosition -> idle
518
+ let idleSel = NSSelectorFromString("mapView:idleAtCameraPosition:")
519
+ if let original = class_getInstanceMethod(delegateClass, idleSel) {
520
+ let originalIMP = method_getImplementation(original)
521
+ _originalIdleAtCamera = originalIMP
522
+ _hookedDelegateClass = delegateClass
523
+
524
+ let block: @convention(block) (AnyObject, AnyObject, AnyObject) -> Void = { [weak self] obj, mapView, cameraPos in
525
+ self?.mapIdle = true
526
+ typealias FnType = @convention(c) (AnyObject, Selector, AnyObject, AnyObject) -> Void
527
+ let fn = unsafeBitCast(originalIMP, to: FnType.self)
528
+ fn(obj, idleSel, mapView, cameraPos)
529
+ }
530
+ let newIMP = imp_implementationWithBlock(block)
531
+ method_setImplementation(original, newIMP)
532
+ }
533
+
534
+ // willMove -> not idle
535
+ let willMoveSel = NSSelectorFromString("mapView:willMove:")
536
+ if let original = class_getInstanceMethod(delegateClass, willMoveSel) {
537
+ let originalIMP = method_getImplementation(original)
538
+ _originalWillMove = originalIMP
539
+
540
+ let block: @convention(block) (AnyObject, AnyObject, Bool) -> Void = { [weak self] obj, mapView, gesture in
541
+ self?.mapIdle = false
542
+ typealias FnType = @convention(c) (AnyObject, Selector, AnyObject, Bool) -> Void
543
+ let fn = unsafeBitCast(originalIMP, to: FnType.self)
544
+ fn(obj, willMoveSel, mapView, gesture)
545
+ }
546
+ let newIMP = imp_implementationWithBlock(block)
547
+ method_setImplementation(original, newIMP)
548
+ }
549
+
550
+ DiagnosticLog.trace("[SpecialCases] Hooked Google Maps delegate on \(delegateClass)")
551
+ }
552
+
553
+ // MARK: - Unhook / cleanup
554
+
555
+ private func _unhookPreviousDelegate() {
556
+ // Restore original IMPs if we have them
557
+ if let cls = _hookedDelegateClass {
558
+ if let imp = _originalRegionDidChange,
559
+ let m = class_getInstanceMethod(cls, NSSelectorFromString("mapView:regionDidChangeAnimated:")) {
560
+ method_setImplementation(m, imp)
561
+ }
562
+ if let imp = _originalRegionWillChange,
563
+ let m = class_getInstanceMethod(cls, NSSelectorFromString("mapView:regionWillChangeAnimated:")) {
564
+ method_setImplementation(m, imp)
565
+ }
566
+ if let imp = _originalIdleAtCamera,
567
+ let m = class_getInstanceMethod(cls, NSSelectorFromString("mapView:idleAtCameraPosition:")) {
568
+ method_setImplementation(m, imp)
569
+ }
570
+ if let imp = _originalWillMove,
571
+ let m = class_getInstanceMethod(cls, NSSelectorFromString("mapView:willMove:")) {
572
+ method_setImplementation(m, imp)
573
+ }
574
+ }
575
+ _hookedDelegateClass = nil
576
+ _hookedMapView = nil
577
+ _originalRegionDidChange = nil
578
+ _originalRegionWillChange = nil
579
+ _originalIdleAtCamera = nil
580
+ _originalWillMove = nil
581
+
582
+ // Remove gesture recognizer targets
583
+ for gr in _observedGestureRecognizers {
584
+ gr.removeTarget(self, action: #selector(_handleMapGesture(_:)))
585
+ }
586
+ _observedGestureRecognizers.removeAll()
587
+ _activeGestureCount = 0
588
+ }
589
+
590
+ private func _clearMapState() {
591
+ if mapVisible {
592
+ _unhookPreviousDelegate()
593
+ }
594
+ mapVisible = false
595
+ mapIdle = true
596
+ detectedSDK = nil
597
+ _usesGestureBasedIdle = false
598
+ _gestureDebounceTimer?.invalidate()
599
+ _gestureDebounceTimer = nil
600
+ }
601
+
602
+ // MARK: - Helpers
603
+
604
+ private func _keyWindow() -> UIWindow? {
605
+ if #available(iOS 15.0, *) {
606
+ return UIApplication.shared.connectedScenes
607
+ .compactMap { $0 as? UIWindowScene }
608
+ .flatMap { $0.windows }
609
+ .first { $0.isKeyWindow }
610
+ } else {
611
+ return UIApplication.shared.windows.first { $0.isKeyWindow }
612
+ }
613
+ }
614
+ }