@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.
- package/README.md +1 -1
- package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +109 -26
- package/android/src/main/java/com/rejourney/engine/DeviceRegistrar.kt +18 -3
- package/android/src/main/java/com/rejourney/engine/RejourneyImpl.kt +69 -17
- package/android/src/main/java/com/rejourney/recording/AnrSentinel.kt +27 -2
- package/android/src/main/java/com/rejourney/recording/InteractionRecorder.kt +30 -0
- package/android/src/main/java/com/rejourney/recording/RejourneyNetworkInterceptor.kt +100 -0
- package/android/src/main/java/com/rejourney/recording/ReplayOrchestrator.kt +260 -174
- package/android/src/main/java/com/rejourney/recording/SegmentDispatcher.kt +246 -34
- package/android/src/main/java/com/rejourney/recording/SpecialCases.kt +572 -0
- package/android/src/main/java/com/rejourney/recording/StabilityMonitor.kt +3 -0
- package/android/src/main/java/com/rejourney/recording/TelemetryPipeline.kt +19 -4
- package/android/src/main/java/com/rejourney/recording/ViewHierarchyScanner.kt +8 -0
- package/android/src/main/java/com/rejourney/recording/VisualCapture.kt +251 -85
- package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +14 -0
- package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +18 -0
- package/ios/Engine/DeviceRegistrar.swift +13 -3
- package/ios/Engine/RejourneyImpl.swift +202 -133
- package/ios/Recording/AnrSentinel.swift +58 -25
- package/ios/Recording/InteractionRecorder.swift +29 -0
- package/ios/Recording/RejourneyURLProtocol.swift +168 -0
- package/ios/Recording/ReplayOrchestrator.swift +241 -147
- package/ios/Recording/SegmentDispatcher.swift +155 -13
- package/ios/Recording/SpecialCases.swift +614 -0
- package/ios/Recording/StabilityMonitor.swift +42 -34
- package/ios/Recording/TelemetryPipeline.swift +38 -3
- package/ios/Recording/ViewHierarchyScanner.swift +1 -0
- package/ios/Recording/VisualCapture.swift +104 -28
- package/ios/Rejourney.mm +27 -8
- package/ios/Utility/ImageBlur.swift +0 -1
- package/lib/commonjs/index.js +32 -20
- package/lib/commonjs/sdk/autoTracking.js +162 -11
- package/lib/commonjs/sdk/constants.js +2 -2
- package/lib/commonjs/sdk/networkInterceptor.js +84 -4
- package/lib/commonjs/sdk/utils.js +1 -1
- package/lib/module/index.js +32 -20
- package/lib/module/sdk/autoTracking.js +162 -11
- package/lib/module/sdk/constants.js +2 -2
- package/lib/module/sdk/networkInterceptor.js +84 -4
- package/lib/module/sdk/utils.js +1 -1
- package/lib/typescript/NativeRejourney.d.ts +5 -2
- package/lib/typescript/sdk/autoTracking.d.ts +3 -1
- package/lib/typescript/sdk/constants.d.ts +2 -2
- package/lib/typescript/types/index.d.ts +15 -8
- package/package.json +4 -4
- package/src/NativeRejourney.ts +8 -5
- package/src/index.ts +46 -29
- package/src/sdk/autoTracking.ts +176 -11
- package/src/sdk/constants.ts +2 -2
- package/src/sdk/networkInterceptor.ts +110 -1
- package/src/sdk/utils.ts +1 -1
- 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
|
+
}
|