@rejourneyco/react-native 1.0.0

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 (152) hide show
  1. package/android/build.gradle.kts +135 -0
  2. package/android/consumer-rules.pro +10 -0
  3. package/android/proguard-rules.pro +1 -0
  4. package/android/src/main/AndroidManifest.xml +15 -0
  5. package/android/src/main/java/com/rejourney/RejourneyModuleImpl.kt +2981 -0
  6. package/android/src/main/java/com/rejourney/capture/ANRHandler.kt +206 -0
  7. package/android/src/main/java/com/rejourney/capture/ActivityTracker.kt +98 -0
  8. package/android/src/main/java/com/rejourney/capture/CaptureEngine.kt +1553 -0
  9. package/android/src/main/java/com/rejourney/capture/CaptureHeuristics.kt +375 -0
  10. package/android/src/main/java/com/rejourney/capture/CrashHandler.kt +153 -0
  11. package/android/src/main/java/com/rejourney/capture/MotionEvent.kt +215 -0
  12. package/android/src/main/java/com/rejourney/capture/SegmentUploader.kt +512 -0
  13. package/android/src/main/java/com/rejourney/capture/VideoEncoder.kt +773 -0
  14. package/android/src/main/java/com/rejourney/capture/ViewHierarchyScanner.kt +633 -0
  15. package/android/src/main/java/com/rejourney/capture/ViewSerializer.kt +286 -0
  16. package/android/src/main/java/com/rejourney/core/Constants.kt +117 -0
  17. package/android/src/main/java/com/rejourney/core/Logger.kt +93 -0
  18. package/android/src/main/java/com/rejourney/core/Types.kt +124 -0
  19. package/android/src/main/java/com/rejourney/lifecycle/SessionLifecycleService.kt +162 -0
  20. package/android/src/main/java/com/rejourney/network/DeviceAuthManager.kt +747 -0
  21. package/android/src/main/java/com/rejourney/network/HttpClientProvider.kt +16 -0
  22. package/android/src/main/java/com/rejourney/network/NetworkMonitor.kt +272 -0
  23. package/android/src/main/java/com/rejourney/network/UploadManager.kt +1363 -0
  24. package/android/src/main/java/com/rejourney/network/UploadWorker.kt +492 -0
  25. package/android/src/main/java/com/rejourney/privacy/PrivacyMask.kt +645 -0
  26. package/android/src/main/java/com/rejourney/touch/GestureClassifier.kt +233 -0
  27. package/android/src/main/java/com/rejourney/touch/KeyboardTracker.kt +158 -0
  28. package/android/src/main/java/com/rejourney/touch/TextInputTracker.kt +181 -0
  29. package/android/src/main/java/com/rejourney/touch/TouchInterceptor.kt +591 -0
  30. package/android/src/main/java/com/rejourney/utils/EventBuffer.kt +284 -0
  31. package/android/src/main/java/com/rejourney/utils/OEMDetector.kt +154 -0
  32. package/android/src/main/java/com/rejourney/utils/PerfTiming.kt +235 -0
  33. package/android/src/main/java/com/rejourney/utils/Telemetry.kt +297 -0
  34. package/android/src/main/java/com/rejourney/utils/WindowUtils.kt +84 -0
  35. package/android/src/newarch/java/com/rejourney/RejourneyModule.kt +187 -0
  36. package/android/src/newarch/java/com/rejourney/RejourneyPackage.kt +40 -0
  37. package/android/src/oldarch/java/com/rejourney/RejourneyModule.kt +218 -0
  38. package/android/src/oldarch/java/com/rejourney/RejourneyPackage.kt +23 -0
  39. package/ios/Capture/RJANRHandler.h +42 -0
  40. package/ios/Capture/RJANRHandler.m +328 -0
  41. package/ios/Capture/RJCaptureEngine.h +275 -0
  42. package/ios/Capture/RJCaptureEngine.m +2062 -0
  43. package/ios/Capture/RJCaptureHeuristics.h +80 -0
  44. package/ios/Capture/RJCaptureHeuristics.m +903 -0
  45. package/ios/Capture/RJCrashHandler.h +46 -0
  46. package/ios/Capture/RJCrashHandler.m +313 -0
  47. package/ios/Capture/RJMotionEvent.h +183 -0
  48. package/ios/Capture/RJMotionEvent.m +183 -0
  49. package/ios/Capture/RJPerformanceManager.h +100 -0
  50. package/ios/Capture/RJPerformanceManager.m +373 -0
  51. package/ios/Capture/RJPixelBufferDownscaler.h +42 -0
  52. package/ios/Capture/RJPixelBufferDownscaler.m +85 -0
  53. package/ios/Capture/RJSegmentUploader.h +146 -0
  54. package/ios/Capture/RJSegmentUploader.m +778 -0
  55. package/ios/Capture/RJVideoEncoder.h +247 -0
  56. package/ios/Capture/RJVideoEncoder.m +1036 -0
  57. package/ios/Capture/RJViewControllerTracker.h +73 -0
  58. package/ios/Capture/RJViewControllerTracker.m +508 -0
  59. package/ios/Capture/RJViewHierarchyScanner.h +215 -0
  60. package/ios/Capture/RJViewHierarchyScanner.m +1464 -0
  61. package/ios/Capture/RJViewSerializer.h +119 -0
  62. package/ios/Capture/RJViewSerializer.m +498 -0
  63. package/ios/Core/RJConstants.h +124 -0
  64. package/ios/Core/RJConstants.m +88 -0
  65. package/ios/Core/RJLifecycleManager.h +85 -0
  66. package/ios/Core/RJLifecycleManager.m +308 -0
  67. package/ios/Core/RJLogger.h +61 -0
  68. package/ios/Core/RJLogger.m +211 -0
  69. package/ios/Core/RJTypes.h +176 -0
  70. package/ios/Core/RJTypes.m +66 -0
  71. package/ios/Core/Rejourney.h +64 -0
  72. package/ios/Core/Rejourney.mm +2495 -0
  73. package/ios/Network/RJDeviceAuthManager.h +94 -0
  74. package/ios/Network/RJDeviceAuthManager.m +967 -0
  75. package/ios/Network/RJNetworkMonitor.h +68 -0
  76. package/ios/Network/RJNetworkMonitor.m +267 -0
  77. package/ios/Network/RJRetryManager.h +73 -0
  78. package/ios/Network/RJRetryManager.m +325 -0
  79. package/ios/Network/RJUploadManager.h +267 -0
  80. package/ios/Network/RJUploadManager.m +2296 -0
  81. package/ios/Privacy/RJPrivacyMask.h +163 -0
  82. package/ios/Privacy/RJPrivacyMask.m +922 -0
  83. package/ios/Rejourney.h +63 -0
  84. package/ios/Touch/RJGestureClassifier.h +130 -0
  85. package/ios/Touch/RJGestureClassifier.m +333 -0
  86. package/ios/Touch/RJTouchInterceptor.h +169 -0
  87. package/ios/Touch/RJTouchInterceptor.m +772 -0
  88. package/ios/Utils/RJEventBuffer.h +112 -0
  89. package/ios/Utils/RJEventBuffer.m +358 -0
  90. package/ios/Utils/RJGzipUtils.h +33 -0
  91. package/ios/Utils/RJGzipUtils.m +89 -0
  92. package/ios/Utils/RJKeychainManager.h +48 -0
  93. package/ios/Utils/RJKeychainManager.m +111 -0
  94. package/ios/Utils/RJPerfTiming.h +209 -0
  95. package/ios/Utils/RJPerfTiming.m +264 -0
  96. package/ios/Utils/RJTelemetry.h +92 -0
  97. package/ios/Utils/RJTelemetry.m +320 -0
  98. package/ios/Utils/RJWindowUtils.h +66 -0
  99. package/ios/Utils/RJWindowUtils.m +133 -0
  100. package/lib/commonjs/NativeRejourney.js +40 -0
  101. package/lib/commonjs/components/Mask.js +79 -0
  102. package/lib/commonjs/index.js +1381 -0
  103. package/lib/commonjs/sdk/autoTracking.js +1259 -0
  104. package/lib/commonjs/sdk/constants.js +151 -0
  105. package/lib/commonjs/sdk/errorTracking.js +199 -0
  106. package/lib/commonjs/sdk/index.js +50 -0
  107. package/lib/commonjs/sdk/metricsTracking.js +204 -0
  108. package/lib/commonjs/sdk/navigation.js +151 -0
  109. package/lib/commonjs/sdk/networkInterceptor.js +412 -0
  110. package/lib/commonjs/sdk/utils.js +363 -0
  111. package/lib/commonjs/types/expo-router.d.js +2 -0
  112. package/lib/commonjs/types/index.js +2 -0
  113. package/lib/module/NativeRejourney.js +38 -0
  114. package/lib/module/components/Mask.js +72 -0
  115. package/lib/module/index.js +1284 -0
  116. package/lib/module/sdk/autoTracking.js +1233 -0
  117. package/lib/module/sdk/constants.js +145 -0
  118. package/lib/module/sdk/errorTracking.js +189 -0
  119. package/lib/module/sdk/index.js +12 -0
  120. package/lib/module/sdk/metricsTracking.js +187 -0
  121. package/lib/module/sdk/navigation.js +143 -0
  122. package/lib/module/sdk/networkInterceptor.js +401 -0
  123. package/lib/module/sdk/utils.js +342 -0
  124. package/lib/module/types/expo-router.d.js +2 -0
  125. package/lib/module/types/index.js +2 -0
  126. package/lib/typescript/NativeRejourney.d.ts +147 -0
  127. package/lib/typescript/components/Mask.d.ts +39 -0
  128. package/lib/typescript/index.d.ts +117 -0
  129. package/lib/typescript/sdk/autoTracking.d.ts +204 -0
  130. package/lib/typescript/sdk/constants.d.ts +120 -0
  131. package/lib/typescript/sdk/errorTracking.d.ts +32 -0
  132. package/lib/typescript/sdk/index.d.ts +9 -0
  133. package/lib/typescript/sdk/metricsTracking.d.ts +58 -0
  134. package/lib/typescript/sdk/navigation.d.ts +33 -0
  135. package/lib/typescript/sdk/networkInterceptor.d.ts +47 -0
  136. package/lib/typescript/sdk/utils.d.ts +148 -0
  137. package/lib/typescript/types/index.d.ts +624 -0
  138. package/package.json +102 -0
  139. package/rejourney.podspec +21 -0
  140. package/src/NativeRejourney.ts +165 -0
  141. package/src/components/Mask.tsx +80 -0
  142. package/src/index.ts +1459 -0
  143. package/src/sdk/autoTracking.ts +1373 -0
  144. package/src/sdk/constants.ts +134 -0
  145. package/src/sdk/errorTracking.ts +231 -0
  146. package/src/sdk/index.ts +11 -0
  147. package/src/sdk/metricsTracking.ts +232 -0
  148. package/src/sdk/navigation.ts +157 -0
  149. package/src/sdk/networkInterceptor.ts +440 -0
  150. package/src/sdk/utils.ts +369 -0
  151. package/src/types/expo-router.d.ts +7 -0
  152. package/src/types/index.ts +739 -0
@@ -0,0 +1,903 @@
1
+ //
2
+ // RJCaptureHeuristics.m
3
+ // Rejourney
4
+ //
5
+ // Capture heuristics implementation.
6
+ //
7
+ // Copyright (c) 2026 Rejourney
8
+ //
9
+
10
+ #import "RJCaptureHeuristics.h"
11
+ #import "../Core/RJLogger.h"
12
+ #import <QuartzCore/QuartzCore.h>
13
+
14
+ static const NSTimeInterval kRJCaptureGraceSeconds = 0.9;
15
+ static const NSTimeInterval kRJPollIntervalSeconds = 0.08;
16
+ static const NSTimeInterval kRJMaxStaleSeconds = 5.0;
17
+
18
+ static const NSTimeInterval kRJQuietTouchSeconds = 0.12;
19
+ static const NSTimeInterval kRJQuietScrollSeconds = 0.2;
20
+ static const NSTimeInterval kRJQuietBounceSeconds = 0.2;
21
+ static const NSTimeInterval kRJQuietRefreshSeconds = 0.22;
22
+ static const NSTimeInterval kRJQuietMapSeconds = 0.55;
23
+ static const NSTimeInterval kRJQuietTransitionSeconds = 0.2;
24
+ static const NSTimeInterval kRJQuietKeyboardSeconds = 0.25;
25
+ static const NSTimeInterval kRJQuietAnimationSeconds = 0.25;
26
+
27
+ static const NSTimeInterval kRJMapSettleSeconds = 0.8;
28
+
29
+ static const NSTimeInterval kRJSignatureChurnWindowSeconds = 0.25;
30
+
31
+ static const NSTimeInterval kRJBonusScrollDelaySeconds = 0.12;
32
+ static const NSTimeInterval kRJBonusMapDelaySeconds = 0.35;
33
+ static const NSTimeInterval kRJBonusRefreshDelaySeconds = 0.2;
34
+ static const NSTimeInterval kRJBonusInteractionDelaySeconds = 0.15;
35
+ static const NSTimeInterval kRJBonusTransitionDelaySeconds = 0.2;
36
+ static const NSTimeInterval kRJBonusKeyboardDelaySeconds = 0.2;
37
+ static const NSTimeInterval kRJBonusAnimationDelaySeconds = 0.2;
38
+
39
+ static const CGFloat kRJAnimationSmallAreaAllowed = 0.03;
40
+
41
+ static const NSTimeInterval kRJKeyframeSpacingSeconds = 0.25;
42
+ static const NSUInteger kRJMaxPendingKeyframes = 3;
43
+
44
+ static const CGFloat kRJScrollEpsilon = 0.5;
45
+ static const CGFloat kRJZoomEpsilon = 0.01;
46
+ static const CGFloat kRJInsetEpsilon = 0.5;
47
+
48
+ typedef struct {
49
+ double latitude;
50
+ double longitude;
51
+ } RJCoordinate;
52
+
53
+ typedef struct {
54
+ double latitudeDelta;
55
+ double longitudeDelta;
56
+ } RJCoordinateSpan;
57
+
58
+ @interface RJScrollViewSample : NSObject
59
+
60
+ @property(nonatomic, assign) CGPoint contentOffset;
61
+ @property(nonatomic, assign) UIEdgeInsets contentInset;
62
+ @property(nonatomic, assign) CGFloat zoomScale;
63
+
64
+ @end
65
+
66
+ @implementation RJScrollViewSample
67
+ @end
68
+
69
+ @interface RJCaptureHeuristics ()
70
+
71
+ @property(nonatomic, assign) NSTimeInterval lastTouchTime;
72
+ @property(nonatomic, assign) NSTimeInterval lastScrollTime;
73
+ @property(nonatomic, assign) NSTimeInterval lastBounceTime;
74
+ @property(nonatomic, assign) NSTimeInterval lastRefreshTime;
75
+ @property(nonatomic, assign) NSTimeInterval lastMapTime;
76
+ @property(nonatomic, assign) NSTimeInterval lastTransitionTime;
77
+ @property(nonatomic, assign) NSTimeInterval lastKeyboardTime;
78
+ @property(nonatomic, assign) NSTimeInterval lastAnimationTime;
79
+ @property(nonatomic, assign) NSTimeInterval mapSettleUntil;
80
+
81
+ @property(nonatomic, assign) NSTimeInterval lastRenderedTime;
82
+ @property(nonatomic, copy, nullable) NSString *lastRenderedSignature;
83
+
84
+ @property(nonatomic, assign, readwrite) BOOL keyboardAnimating;
85
+ @property(nonatomic, assign, readwrite) BOOL scrollActive;
86
+ @property(nonatomic, assign, readwrite) BOOL refreshActive;
87
+ @property(nonatomic, assign, readwrite) BOOL mapActive;
88
+ @property(nonatomic, assign, readwrite) BOOL animationBlocking;
89
+ @property(nonatomic, assign) CGFloat lastAnimationAreaRatio;
90
+
91
+ @property(nonatomic, copy, nullable) NSString *lastObservedSignature;
92
+ @property(nonatomic, assign) NSTimeInterval lastObservedSignatureTime;
93
+ @property(nonatomic, assign) NSUInteger signatureChurnCount;
94
+ @property(nonatomic, assign) NSTimeInterval lastSignatureChurnTime;
95
+ @property(nonatomic, assign) BOOL churnBlocking;
96
+
97
+ @property(nonatomic, assign) BOOL hasVideoSurface;
98
+ @property(nonatomic, assign) BOOL hasWebSurface;
99
+ @property(nonatomic, assign) BOOL hasCameraSurface;
100
+
101
+ @property(nonatomic, weak) UIViewController *lastTopVC;
102
+
103
+ @property(nonatomic, assign) NSTimeInterval bonusCaptureTime;
104
+ @property(nonatomic, assign) NSUInteger pendingKeyframes;
105
+ @property(nonatomic, assign) NSTimeInterval lastKeyframeRenderTime;
106
+
107
+ @property(nonatomic, strong)
108
+ NSMapTable<UIScrollView *, RJScrollViewSample *> *scrollSamples;
109
+ @property(nonatomic, strong) NSHashTable<UIView *> *animatedViews;
110
+ @property(nonatomic, strong) NSHashTable<UIView *> *mapViews;
111
+ @property(nonatomic, strong) NSMapTable<UIView *, NSString *> *mapStates;
112
+
113
+ @end
114
+
115
+ @implementation RJCaptureHeuristicsDecision
116
+ @end
117
+
118
+ @implementation RJCaptureHeuristics
119
+
120
+ - (instancetype)init {
121
+ self = [super init];
122
+ if (self) {
123
+ _captureGraceSeconds = kRJCaptureGraceSeconds;
124
+ _pollIntervalSeconds = kRJPollIntervalSeconds;
125
+ _maxStaleSeconds = kRJMaxStaleSeconds;
126
+ _scrollSamples = [NSMapTable weakToStrongObjectsMapTable];
127
+ _animatedViews = [NSHashTable weakObjectsHashTable];
128
+ _mapViews = [NSHashTable weakObjectsHashTable];
129
+ _mapStates = [NSMapTable weakToStrongObjectsMapTable];
130
+
131
+ NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
132
+ [center addObserver:self
133
+ selector:@selector(handleKeyboardWillChange:)
134
+ name:UIKeyboardWillShowNotification
135
+ object:nil];
136
+ [center addObserver:self
137
+ selector:@selector(handleKeyboardWillChange:)
138
+ name:UIKeyboardWillHideNotification
139
+ object:nil];
140
+ [center addObserver:self
141
+ selector:@selector(handleKeyboardWillChange:)
142
+ name:UIKeyboardWillChangeFrameNotification
143
+ object:nil];
144
+ [center addObserver:self
145
+ selector:@selector(handleKeyboardDidChange:)
146
+ name:UIKeyboardDidShowNotification
147
+ object:nil];
148
+ [center addObserver:self
149
+ selector:@selector(handleKeyboardDidChange:)
150
+ name:UIKeyboardDidHideNotification
151
+ object:nil];
152
+ [center addObserver:self
153
+ selector:@selector(handleKeyboardDidChange:)
154
+ name:UIKeyboardDidChangeFrameNotification
155
+ object:nil];
156
+ }
157
+ return self;
158
+ }
159
+
160
+ - (void)dealloc {
161
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
162
+ }
163
+
164
+ - (void)reset {
165
+ self.lastTouchTime = 0;
166
+ self.lastScrollTime = 0;
167
+ self.lastBounceTime = 0;
168
+ self.lastRefreshTime = 0;
169
+ self.lastMapTime = 0;
170
+ self.lastTransitionTime = 0;
171
+ self.lastKeyboardTime = 0;
172
+ self.lastAnimationTime = 0;
173
+ self.mapSettleUntil = 0;
174
+ self.lastRenderedTime = 0;
175
+ self.lastRenderedSignature = nil;
176
+ self.lastObservedSignature = nil;
177
+ self.lastObservedSignatureTime = 0;
178
+ self.signatureChurnCount = 0;
179
+ self.lastSignatureChurnTime = 0;
180
+ self.churnBlocking = NO;
181
+ self.keyboardAnimating = NO;
182
+ self.scrollActive = NO;
183
+ self.refreshActive = NO;
184
+ self.mapActive = NO;
185
+ self.animationBlocking = NO;
186
+ self.lastAnimationAreaRatio = 0.0;
187
+ self.hasVideoSurface = NO;
188
+ self.hasWebSurface = NO;
189
+ self.hasCameraSurface = NO;
190
+ self.bonusCaptureTime = 0;
191
+ self.pendingKeyframes = 0;
192
+ self.lastKeyframeRenderTime = 0;
193
+ self.lastTopVC = nil;
194
+ [self.scrollSamples removeAllObjects];
195
+ [self.animatedViews removeAllObjects];
196
+ [self.mapViews removeAllObjects];
197
+ [self.mapStates removeAllObjects];
198
+ }
199
+
200
+ - (void)invalidateSignature {
201
+ self.lastRenderedSignature = nil;
202
+ self.lastRenderedTime = 0;
203
+ }
204
+
205
+ - (void)recordTouchEventAtTime:(NSTimeInterval)time {
206
+ self.lastTouchTime = time;
207
+ [self scheduleBonusCaptureAfterDelay:kRJBonusInteractionDelaySeconds now:time];
208
+ }
209
+
210
+ - (void)recordInteractionEventAtTime:(NSTimeInterval)time {
211
+ [self recordTouchEventAtTime:time];
212
+ }
213
+
214
+ - (void)recordMapInteractionAtTime:(NSTimeInterval)time {
215
+ self.lastMapTime = time;
216
+ NSTimeInterval candidate = time + kRJMapSettleSeconds;
217
+ if (candidate > self.mapSettleUntil) {
218
+ self.mapSettleUntil = candidate;
219
+ }
220
+ }
221
+
222
+ - (void)recordNavigationEventAtTime:(NSTimeInterval)time {
223
+ self.lastTransitionTime = time;
224
+ [self scheduleBonusCaptureAfterDelay:kRJBonusTransitionDelaySeconds
225
+ now:time];
226
+ }
227
+
228
+ - (void)recordRenderedSignature:(nullable NSString *)signature
229
+ atTime:(NSTimeInterval)time {
230
+ self.lastRenderedSignature = signature.length > 0 ? signature : nil;
231
+ self.lastRenderedTime = time;
232
+ if (self.pendingKeyframes > 0) {
233
+ self.pendingKeyframes -= 1;
234
+ self.lastKeyframeRenderTime = time;
235
+ if (self.pendingKeyframes > 0) {
236
+ self.bonusCaptureTime = time + kRJKeyframeSpacingSeconds;
237
+ return;
238
+ }
239
+ }
240
+ self.bonusCaptureTime = 0;
241
+ }
242
+
243
+ - (void)updateWithScanResult:(RJViewHierarchyScanResult *)scanResult
244
+ window:(UIWindow *)window
245
+ now:(NSTimeInterval)now {
246
+ if (!scanResult) {
247
+ return;
248
+ }
249
+
250
+ [self updateTouchStateAtTime:now];
251
+ [self updateTransitionStateForWindow:window atTime:now];
252
+
253
+ NSString *currentSignature = scanResult.layoutSignature ?: @"";
254
+ NSString *lastSignature = self.lastObservedSignature ?: @"";
255
+ BOOL signatureChanged = ![currentSignature isEqualToString:lastSignature];
256
+ if (signatureChanged) {
257
+ NSTimeInterval delta = now - self.lastObservedSignatureTime;
258
+ if (delta < kRJSignatureChurnWindowSeconds) {
259
+ self.signatureChurnCount += 1;
260
+ } else {
261
+ self.signatureChurnCount = 1;
262
+ }
263
+ self.lastObservedSignatureTime = now;
264
+ self.lastSignatureChurnTime = now;
265
+ self.lastObservedSignature = currentSignature.length > 0 ? currentSignature
266
+ : nil;
267
+ } else if (self.lastSignatureChurnTime > 0 &&
268
+ (now - self.lastSignatureChurnTime) >
269
+ kRJSignatureChurnWindowSeconds) {
270
+ self.signatureChurnCount = 0;
271
+ }
272
+ self.churnBlocking =
273
+ (self.signatureChurnCount >= 2 &&
274
+ (now - self.lastSignatureChurnTime) < kRJSignatureChurnWindowSeconds);
275
+
276
+ self.hasVideoSurface = (scanResult.videoFrames.count > 0);
277
+ self.hasWebSurface = (scanResult.webViewFrames.count > 0);
278
+ self.hasCameraSurface = (scanResult.cameraFrames.count > 0);
279
+
280
+ if (scanResult.scrollActive) {
281
+ // Active scroll is a strong blocker: rendering during drag or deceleration
282
+ // causes visible hitching.
283
+ self.lastScrollTime = now;
284
+ }
285
+ if (scanResult.bounceActive) {
286
+ // Rubber-band bounce and inset settling stutter easily, so delay capture.
287
+ self.lastBounceTime = now;
288
+ }
289
+ if (scanResult.refreshActive) {
290
+ // Pull-to-refresh animations and insets can hitch; wait for settle.
291
+ self.lastRefreshTime = now;
292
+ }
293
+ if (scanResult.mapActive) {
294
+ // Map camera or tile motion is expensive; never capture mid-motion.
295
+ [self recordMapInteractionAtTime:now];
296
+ }
297
+
298
+ [self updateScrollActiveState:scanResult.scrollActive
299
+ refreshActive:scanResult.refreshActive
300
+ mapActive:scanResult.mapActive
301
+ now:now];
302
+
303
+ BOOL blockingAnimation = NO;
304
+ if (scanResult.hasAnyAnimations) {
305
+ self.lastAnimationAreaRatio = scanResult.animationAreaRatio;
306
+ blockingAnimation = (scanResult.animationAreaRatio >=
307
+ kRJAnimationSmallAreaAllowed);
308
+ } else {
309
+ self.lastAnimationAreaRatio = 0.0;
310
+ }
311
+
312
+ BOOL recentSignatureChange =
313
+ signatureChanged ||
314
+ (self.signatureChurnCount > 0 &&
315
+ (now - self.lastSignatureChurnTime) < kRJSignatureChurnWindowSeconds);
316
+ BOOL bailoutBlocking = (scanResult.didBailOutEarly && recentSignatureChange);
317
+ BOOL shouldBlock = blockingAnimation || self.churnBlocking || bailoutBlocking;
318
+ self.animationBlocking = shouldBlock;
319
+ if (shouldBlock) {
320
+ self.lastAnimationTime = now;
321
+ }
322
+
323
+ [self updateTrackedScrollViews:scanResult.scrollViewPointers now:now];
324
+ [self updateTrackedMapViews:scanResult.mapViewPointers now:now];
325
+ [self updateTrackedAnimatedViews:scanResult.animatedViewPointers];
326
+ }
327
+
328
+ - (void)updateWithStabilityProbeForWindow:(UIWindow *)window
329
+ now:(NSTimeInterval)now {
330
+ [self updateTouchStateAtTime:now];
331
+ [self updateTransitionStateForWindow:window atTime:now];
332
+
333
+ [self probeScrollViewsAtTime:now];
334
+ [self probeMapViewsAtTime:now];
335
+ [self probeAnimatedViewsAtTime:now];
336
+ }
337
+
338
+ - (RJCaptureHeuristicsDecision *)decisionForSignature:
339
+ (nullable NSString *)signature
340
+ now:(NSTimeInterval)now
341
+ hasLastFrame:(BOOL)hasLastFrame {
342
+ RJCaptureHeuristicsDecision *decision =
343
+ [[RJCaptureHeuristicsDecision alloc] init];
344
+
345
+ NSTimeInterval earliestSafeTime = now;
346
+ RJCaptureHeuristicsReason blockerReason = RJCaptureHeuristicsReasonRenderNow;
347
+
348
+ // Active touch/gesture input should be avoided to keep input latency smooth.
349
+ [self considerBlockerSince:self.lastTouchTime
350
+ quietInterval:kRJQuietTouchSeconds
351
+ now:now
352
+ reason:RJCaptureHeuristicsReasonDeferTouch
353
+ earliestTime:&earliestSafeTime
354
+ chosenReason:&blockerReason];
355
+
356
+ // Scroll motion (dragging or deceleration) is a high-jank period.
357
+ [self considerBlockerSince:self.lastScrollTime
358
+ quietInterval:kRJQuietScrollSeconds
359
+ now:now
360
+ reason:RJCaptureHeuristicsReasonDeferScroll
361
+ earliestTime:&earliestSafeTime
362
+ chosenReason:&blockerReason];
363
+
364
+ // Rubber-band bounce and inset animations are visually sensitive.
365
+ [self considerBlockerSince:self.lastBounceTime
366
+ quietInterval:kRJQuietBounceSeconds
367
+ now:now
368
+ reason:RJCaptureHeuristicsReasonDeferBounce
369
+ earliestTime:&earliestSafeTime
370
+ chosenReason:&blockerReason];
371
+
372
+ // Pull-to-refresh animations should finish before rendering.
373
+ [self considerBlockerSince:self.lastRefreshTime
374
+ quietInterval:kRJQuietRefreshSeconds
375
+ now:now
376
+ reason:RJCaptureHeuristicsReasonDeferRefresh
377
+ earliestTime:&earliestSafeTime
378
+ chosenReason:&blockerReason];
379
+
380
+ // Interactive transitions (swipe back, drag-to-dismiss) are hitch-sensitive.
381
+ [self considerBlockerSince:self.lastTransitionTime
382
+ quietInterval:kRJQuietTransitionSeconds
383
+ now:now
384
+ reason:RJCaptureHeuristicsReasonDeferTransition
385
+ earliestTime:&earliestSafeTime
386
+ chosenReason:&blockerReason];
387
+
388
+ // Keyboard frame animations can stutter; wait for settle.
389
+ if (self.keyboardAnimating) {
390
+ self.lastKeyboardTime = now;
391
+ }
392
+ [self considerBlockerSince:self.lastKeyboardTime
393
+ quietInterval:kRJQuietKeyboardSeconds
394
+ now:now
395
+ reason:RJCaptureHeuristicsReasonDeferKeyboard
396
+ earliestTime:&earliestSafeTime
397
+ chosenReason:&blockerReason];
398
+
399
+ // Map camera or tile motion is visually obvious; avoid rendering mid-flight.
400
+ [self considerBlockerSince:self.lastMapTime
401
+ quietInterval:kRJQuietMapSeconds
402
+ now:now
403
+ reason:RJCaptureHeuristicsReasonDeferMap
404
+ earliestTime:&earliestSafeTime
405
+ chosenReason:&blockerReason];
406
+
407
+ if (self.mapSettleUntil > now && self.mapSettleUntil > earliestSafeTime) {
408
+ earliestSafeTime = self.mapSettleUntil;
409
+ blockerReason = RJCaptureHeuristicsReasonDeferMap;
410
+ }
411
+
412
+ // Large-area animations (Lottie, shimmer, etc.) are very noticeable.
413
+ if (self.animationBlocking) {
414
+ [self considerBlockerSince:self.lastAnimationTime
415
+ quietInterval:kRJQuietAnimationSeconds
416
+ now:now
417
+ reason:RJCaptureHeuristicsReasonDeferBigAnimation
418
+ earliestTime:&earliestSafeTime
419
+ chosenReason:&blockerReason];
420
+ }
421
+
422
+ if (earliestSafeTime > now) {
423
+ decision.action = RJCaptureHeuristicsActionDefer;
424
+ decision.reason = blockerReason;
425
+ decision.deferUntil = earliestSafeTime;
426
+ return decision;
427
+ }
428
+
429
+ BOOL signatureChanged = (signature.length == 0 ||
430
+ ![signature isEqualToString:self.lastRenderedSignature]);
431
+ BOOL stale = (self.lastRenderedTime <= 0 ||
432
+ (now - self.lastRenderedTime) > self.maxStaleSeconds);
433
+ BOOL bonusDue = (self.bonusCaptureTime > 0 && now >= self.bonusCaptureTime);
434
+ BOOL keyframeDue = bonusDue && self.pendingKeyframes > 0 &&
435
+ (now - self.lastKeyframeRenderTime) >=
436
+ kRJKeyframeSpacingSeconds;
437
+ BOOL staleOnly = stale && hasLastFrame && !signatureChanged && !keyframeDue;
438
+ BOOL suppressStaleRender =
439
+ staleOnly &&
440
+ (self.hasVideoSurface || self.hasWebSurface || self.hasCameraSurface);
441
+
442
+ if (suppressStaleRender) {
443
+ decision.action = RJCaptureHeuristicsActionReuseLast;
444
+ decision.reason = RJCaptureHeuristicsReasonReuseSignatureUnchanged;
445
+ return decision;
446
+ }
447
+
448
+ if (!hasLastFrame || signatureChanged || stale || keyframeDue) {
449
+ decision.action = RJCaptureHeuristicsActionRenderNow;
450
+ decision.reason = RJCaptureHeuristicsReasonRenderNow;
451
+ return decision;
452
+ }
453
+
454
+ decision.action = RJCaptureHeuristicsActionReuseLast;
455
+ decision.reason = RJCaptureHeuristicsReasonReuseSignatureUnchanged;
456
+ return decision;
457
+ }
458
+
459
+ + (NSString *)stringForReason:(RJCaptureHeuristicsReason)reason {
460
+ switch (reason) {
461
+ case RJCaptureHeuristicsReasonRenderNow:
462
+ return @"RENDER_NOW";
463
+ case RJCaptureHeuristicsReasonDeferTouch:
464
+ return @"DEFER_TOUCH";
465
+ case RJCaptureHeuristicsReasonDeferScroll:
466
+ return @"DEFER_SCROLL";
467
+ case RJCaptureHeuristicsReasonDeferBounce:
468
+ return @"DEFER_BOUNCE";
469
+ case RJCaptureHeuristicsReasonDeferRefresh:
470
+ return @"DEFER_REFRESH";
471
+ case RJCaptureHeuristicsReasonDeferTransition:
472
+ return @"DEFER_TRANSITION";
473
+ case RJCaptureHeuristicsReasonDeferKeyboard:
474
+ return @"DEFER_KEYBOARD";
475
+ case RJCaptureHeuristicsReasonDeferMap:
476
+ return @"DEFER_MAP";
477
+ case RJCaptureHeuristicsReasonDeferBigAnimation:
478
+ return @"DEFER_BIG_ANIMATION";
479
+ case RJCaptureHeuristicsReasonReuseSignatureUnchanged:
480
+ return @"REUSE_SIGNATURE_UNCHANGED";
481
+ case RJCaptureHeuristicsReasonDeadlineExpired:
482
+ return @"REUSE_DEADLINE_EXPIRED";
483
+ case RJCaptureHeuristicsReasonRenderFailedReuse:
484
+ return @"RENDER_FAILED_REUSE";
485
+ }
486
+
487
+ return @"";
488
+ }
489
+
490
+ #pragma mark - Keyboard Tracking
491
+
492
+ - (void)handleKeyboardWillChange:(NSNotification *)notification {
493
+ self.keyboardAnimating = YES;
494
+ self.lastKeyboardTime = CACurrentMediaTime();
495
+ }
496
+
497
+ - (void)handleKeyboardDidChange:(NSNotification *)notification {
498
+ self.keyboardAnimating = NO;
499
+ NSTimeInterval now = CACurrentMediaTime();
500
+ self.lastKeyboardTime = now;
501
+ [self scheduleBonusCaptureAfterDelay:kRJBonusKeyboardDelaySeconds now:now];
502
+ }
503
+
504
+ #pragma mark - Touch/Transition Tracking
505
+
506
+ - (void)updateTouchStateAtTime:(NSTimeInterval)now {
507
+ NSString *mode = [[NSRunLoop mainRunLoop] currentMode];
508
+ if ([mode isEqualToString:UITrackingRunLoopMode]) {
509
+ self.lastTouchTime = now;
510
+ self.lastScrollTime = now;
511
+ }
512
+ }
513
+
514
+ - (void)updateTransitionStateForWindow:(UIWindow *)window
515
+ atTime:(NSTimeInterval)now {
516
+ UIViewController *topVC = [self topViewControllerForWindow:window];
517
+ if (topVC != self.lastTopVC) {
518
+ self.lastTransitionTime = now;
519
+ [self scheduleBonusCaptureAfterDelay:kRJBonusTransitionDelaySeconds now:now];
520
+ self.lastTopVC = topVC;
521
+ }
522
+ id<UIViewControllerTransitionCoordinator> coordinator =
523
+ topVC.transitionCoordinator;
524
+ if (coordinator && (coordinator.isInteractive || coordinator.isAnimated)) {
525
+ self.lastTransitionTime = now;
526
+ }
527
+ }
528
+
529
+ - (UIViewController *)topViewControllerForWindow:(UIWindow *)window {
530
+ UIViewController *root = window.rootViewController;
531
+ if (!root) {
532
+ return nil;
533
+ }
534
+ return [self topViewControllerFrom:root];
535
+ }
536
+
537
+ - (UIViewController *)topViewControllerFrom:(UIViewController *)viewController {
538
+ if (viewController.presentedViewController) {
539
+ return [self topViewControllerFrom:viewController.presentedViewController];
540
+ }
541
+ if ([viewController isKindOfClass:[UINavigationController class]]) {
542
+ UINavigationController *nav = (UINavigationController *)viewController;
543
+ return [self topViewControllerFrom:nav.visibleViewController];
544
+ }
545
+ if ([viewController isKindOfClass:[UITabBarController class]]) {
546
+ UITabBarController *tab = (UITabBarController *)viewController;
547
+ return [self topViewControllerFrom:tab.selectedViewController];
548
+ }
549
+ return viewController;
550
+ }
551
+
552
+ #pragma mark - Scroll Tracking
553
+
554
+ - (void)updateTrackedScrollViews:(NSArray<NSValue *> *)scrollViewPointers
555
+ now:(NSTimeInterval)now {
556
+ if (!scrollViewPointers) {
557
+ return;
558
+ }
559
+
560
+ for (NSValue *pointer in scrollViewPointers) {
561
+ UIScrollView *scrollView = [pointer nonretainedObjectValue];
562
+ if (![scrollView isKindOfClass:[UIScrollView class]]) {
563
+ continue;
564
+ }
565
+ [self evaluateScrollView:scrollView
566
+ now:now
567
+ scrollActive:NULL
568
+ refreshActive:NULL];
569
+ }
570
+
571
+ }
572
+
573
+ - (void)probeScrollViewsAtTime:(NSTimeInterval)now {
574
+ BOOL anyScrollActive = NO;
575
+ BOOL anyRefreshActive = NO;
576
+
577
+ NSArray<UIScrollView *> *scrollViews = [[self.scrollSamples keyEnumerator] allObjects];
578
+ if (!scrollViews) {
579
+ scrollViews = @[];
580
+ }
581
+ for (UIScrollView *scrollView in scrollViews) {
582
+ if (![scrollView isKindOfClass:[UIScrollView class]]) {
583
+ continue;
584
+ }
585
+ [self evaluateScrollView:scrollView
586
+ now:now
587
+ scrollActive:&anyScrollActive
588
+ refreshActive:&anyRefreshActive];
589
+ }
590
+
591
+ [self updateScrollActiveState:anyScrollActive
592
+ refreshActive:anyRefreshActive
593
+ mapActive:self.mapActive
594
+ now:now];
595
+ }
596
+
597
+ - (void)evaluateScrollView:(UIScrollView *)scrollView
598
+ now:(NSTimeInterval)now
599
+ scrollActive:(BOOL *)scrollActive
600
+ refreshActive:(BOOL *)refreshActive {
601
+ RJScrollViewSample *sample = [self.scrollSamples objectForKey:scrollView];
602
+ if (!sample) {
603
+ sample = [[RJScrollViewSample alloc] init];
604
+ }
605
+
606
+ CGPoint offset = scrollView.contentOffset;
607
+ UIEdgeInsets inset = scrollView.contentInset;
608
+ CGFloat zoomScale = scrollView.zoomScale;
609
+
610
+ BOOL tracking = scrollView.isTracking || scrollView.isDragging ||
611
+ scrollView.isDecelerating;
612
+ BOOL offsetMoved = (fabs(offset.x - sample.contentOffset.x) > kRJScrollEpsilon ||
613
+ fabs(offset.y - sample.contentOffset.y) > kRJScrollEpsilon);
614
+ BOOL zoomMoved = fabs(zoomScale - sample.zoomScale) > kRJZoomEpsilon;
615
+ BOOL isScrolling = tracking || offsetMoved || zoomMoved;
616
+
617
+ if (isScrolling) {
618
+ // Scroll movement, including momentum/deceleration.
619
+ self.lastScrollTime = now;
620
+ if (scrollActive) {
621
+ *scrollActive = YES;
622
+ }
623
+ }
624
+
625
+ BOOL insetChanged =
626
+ (fabs(inset.top - sample.contentInset.top) > kRJInsetEpsilon ||
627
+ fabs(inset.bottom - sample.contentInset.bottom) > kRJInsetEpsilon ||
628
+ fabs(inset.left - sample.contentInset.left) > kRJInsetEpsilon ||
629
+ fabs(inset.right - sample.contentInset.right) > kRJInsetEpsilon);
630
+
631
+ BOOL isOverscrolled = [self isOverscrolling:scrollView offset:offset];
632
+ if (isOverscrolled || insetChanged) {
633
+ // Rubber-band bounce or inset settling.
634
+ self.lastBounceTime = now;
635
+ }
636
+
637
+ BOOL refreshVisible = [self isRefreshActiveForScrollView:scrollView
638
+ offset:offset
639
+ inset:inset];
640
+ if (refreshVisible) {
641
+ // Pull-to-refresh is active or settling.
642
+ self.lastRefreshTime = now;
643
+ if (refreshActive) {
644
+ *refreshActive = YES;
645
+ }
646
+ }
647
+
648
+ sample.contentOffset = offset;
649
+ sample.contentInset = inset;
650
+ sample.zoomScale = zoomScale;
651
+ [self.scrollSamples setObject:sample forKey:scrollView];
652
+ }
653
+
654
+ - (BOOL)isOverscrolling:(UIScrollView *)scrollView offset:(CGPoint)offset {
655
+ UIEdgeInsets inset = UIEdgeInsetsZero;
656
+ @try {
657
+ inset = scrollView.adjustedContentInset;
658
+ } @catch (NSException *exception) {
659
+ inset = scrollView.contentInset;
660
+ }
661
+ CGFloat topLimit = -inset.top - kRJScrollEpsilon;
662
+ CGFloat bottomLimit = scrollView.contentSize.height -
663
+ scrollView.bounds.size.height +
664
+ inset.bottom + kRJScrollEpsilon;
665
+
666
+ if (offset.y < topLimit || offset.y > bottomLimit) {
667
+ return YES;
668
+ }
669
+
670
+ CGFloat leftLimit = -inset.left - kRJScrollEpsilon;
671
+ CGFloat rightLimit = scrollView.contentSize.width -
672
+ scrollView.bounds.size.width +
673
+ inset.right + kRJScrollEpsilon;
674
+ if (offset.x < leftLimit || offset.x > rightLimit) {
675
+ return YES;
676
+ }
677
+
678
+ return NO;
679
+ }
680
+
681
+ - (BOOL)isRefreshActiveForScrollView:(UIScrollView *)scrollView
682
+ offset:(CGPoint)offset
683
+ inset:(UIEdgeInsets)inset {
684
+ UIRefreshControl *refreshControl = scrollView.refreshControl;
685
+ if (!refreshControl) {
686
+ return NO;
687
+ }
688
+
689
+ if (refreshControl.isRefreshing) {
690
+ return YES;
691
+ }
692
+
693
+ CGFloat triggerOffset = -scrollView.adjustedContentInset.top -
694
+ kRJScrollEpsilon;
695
+ if (offset.y < triggerOffset) {
696
+ return YES;
697
+ }
698
+
699
+ CGRect refreshFrame = refreshControl.frame;
700
+ if (refreshControl.superview) {
701
+ CGRect inScroll = [refreshControl.superview convertRect:refreshFrame
702
+ toView:scrollView];
703
+ if (CGRectIntersectsRect(inScroll, scrollView.bounds)) {
704
+ return YES;
705
+ }
706
+ }
707
+
708
+ return NO;
709
+ }
710
+
711
+ - (void)updateScrollActiveState:(BOOL)scrollActive
712
+ refreshActive:(BOOL)refreshActive
713
+ mapActive:(BOOL)mapActive
714
+ now:(NSTimeInterval)now {
715
+ if (self.scrollActive && !scrollActive) {
716
+ [self scheduleBonusCaptureAfterDelay:kRJBonusScrollDelaySeconds now:now];
717
+ }
718
+ if (self.refreshActive && !refreshActive) {
719
+ [self scheduleBonusCaptureAfterDelay:kRJBonusRefreshDelaySeconds now:now];
720
+ }
721
+ if (self.mapActive && !mapActive) {
722
+ [self scheduleBonusCaptureAfterDelay:kRJBonusMapDelaySeconds now:now];
723
+ NSTimeInterval candidate = now + kRJMapSettleSeconds;
724
+ if (candidate > self.mapSettleUntil) {
725
+ self.mapSettleUntil = candidate;
726
+ }
727
+ }
728
+
729
+ self.scrollActive = scrollActive;
730
+ self.refreshActive = refreshActive;
731
+ self.mapActive = mapActive;
732
+ }
733
+
734
+ #pragma mark - Map Tracking
735
+
736
+ - (void)updateTrackedMapViews:(NSArray<NSValue *> *)mapViewPointers
737
+ now:(NSTimeInterval)now {
738
+ if (!mapViewPointers) {
739
+ return;
740
+ }
741
+
742
+ for (NSValue *pointer in mapViewPointers) {
743
+ UIView *view = [pointer nonretainedObjectValue];
744
+ if (![view isKindOfClass:[UIView class]]) {
745
+ continue;
746
+ }
747
+ [self.mapViews addObject:view];
748
+ [self updateMapStateForView:view atTime:now];
749
+ }
750
+ }
751
+
752
+ - (void)probeMapViewsAtTime:(NSTimeInterval)now {
753
+ BOOL anyMapActive = NO;
754
+ for (UIView *view in self.mapViews) {
755
+ if (![view isKindOfClass:[UIView class]]) {
756
+ continue;
757
+ }
758
+ if ([self updateMapStateForView:view atTime:now]) {
759
+ anyMapActive = YES;
760
+ }
761
+ }
762
+ if (anyMapActive) {
763
+ self.lastMapTime = now;
764
+ }
765
+ [self updateScrollActiveState:self.scrollActive
766
+ refreshActive:self.refreshActive
767
+ mapActive:anyMapActive || self.mapActive
768
+ now:now];
769
+ }
770
+
771
+ - (BOOL)updateMapStateForView:(UIView *)view atTime:(NSTimeInterval)now {
772
+ NSString *signature = [self mapSignatureForView:view];
773
+ if (signature.length == 0) {
774
+ return NO;
775
+ }
776
+
777
+ NSString *previous = [self.mapStates objectForKey:view];
778
+ [self.mapStates setObject:signature forKey:view];
779
+ if (!previous) {
780
+ return NO;
781
+ }
782
+ if (![previous isEqualToString:signature]) {
783
+ [self recordMapInteractionAtTime:now];
784
+ return YES;
785
+ }
786
+ return NO;
787
+ }
788
+
789
+ - (NSString *)mapSignatureForView:(UIView *)view {
790
+ @try {
791
+ NSValue *centerValue = [view valueForKey:@"centerCoordinate"];
792
+ NSValue *spanValue = [view valueForKeyPath:@"region.span"];
793
+
794
+ RJCoordinate center = {0, 0};
795
+ RJCoordinateSpan span = {0, 0};
796
+ if ([centerValue isKindOfClass:[NSValue class]]) {
797
+ [centerValue getValue:&center];
798
+ }
799
+ if ([spanValue isKindOfClass:[NSValue class]]) {
800
+ [spanValue getValue:&span];
801
+ }
802
+
803
+ NSNumber *altitude = [view valueForKeyPath:@"camera.altitude"];
804
+ NSNumber *heading = [view valueForKeyPath:@"camera.heading"];
805
+ NSNumber *pitch = [view valueForKeyPath:@"camera.pitch"];
806
+
807
+ double altitudeValue = altitude ? altitude.doubleValue : 0;
808
+ double headingValue = heading ? heading.doubleValue : 0;
809
+ double pitchValue = pitch ? pitch.doubleValue : 0;
810
+
811
+ return [NSString stringWithFormat:@"%.5f:%.5f:%.5f:%.5f:%.1f:%.1f:%.1f",
812
+ center.latitude, center.longitude,
813
+ span.latitudeDelta, span.longitudeDelta,
814
+ altitudeValue, headingValue, pitchValue];
815
+ } @catch (NSException *exception) {
816
+ return @"";
817
+ }
818
+ }
819
+
820
+ #pragma mark - Animation Tracking
821
+
822
+ - (void)updateTrackedAnimatedViews:(NSArray<NSValue *> *)animatedViewPointers {
823
+ if (!animatedViewPointers) {
824
+ return;
825
+ }
826
+
827
+ [self.animatedViews removeAllObjects];
828
+ for (NSValue *pointer in animatedViewPointers) {
829
+ UIView *view = [pointer nonretainedObjectValue];
830
+ if (![view isKindOfClass:[UIView class]]) {
831
+ continue;
832
+ }
833
+ [self.animatedViews addObject:view];
834
+ }
835
+ }
836
+
837
+ - (void)probeAnimatedViewsAtTime:(NSTimeInterval)now {
838
+ if (self.churnBlocking) {
839
+ if ((now - self.lastSignatureChurnTime) <
840
+ kRJSignatureChurnWindowSeconds) {
841
+ self.lastAnimationTime = now;
842
+ return;
843
+ }
844
+ self.churnBlocking = NO;
845
+ }
846
+
847
+ if (!self.animationBlocking) {
848
+ return;
849
+ }
850
+
851
+ BOOL stillAnimating = NO;
852
+ for (UIView *view in self.animatedViews) {
853
+ if (![view isKindOfClass:[UIView class]]) {
854
+ continue;
855
+ }
856
+ if (view.layer.animationKeys.count > 0) {
857
+ stillAnimating = YES;
858
+ break;
859
+ }
860
+ }
861
+
862
+ if (stillAnimating) {
863
+ self.lastAnimationTime = now;
864
+ } else {
865
+ self.animationBlocking = NO;
866
+ [self scheduleBonusCaptureAfterDelay:kRJBonusAnimationDelaySeconds
867
+ now:now];
868
+ }
869
+ }
870
+
871
+ #pragma mark - Bonus Capture
872
+
873
+ - (void)scheduleBonusCaptureAfterDelay:(NSTimeInterval)delay
874
+ now:(NSTimeInterval)now {
875
+ if (self.pendingKeyframes < kRJMaxPendingKeyframes) {
876
+ self.pendingKeyframes += 1;
877
+ }
878
+ NSTimeInterval candidate = now + delay;
879
+ if (self.bonusCaptureTime <= 0 || candidate < self.bonusCaptureTime) {
880
+ self.bonusCaptureTime = candidate;
881
+ }
882
+ }
883
+
884
+ #pragma mark - Decision Helpers
885
+
886
+ - (void)considerBlockerSince:(NSTimeInterval)timestamp
887
+ quietInterval:(NSTimeInterval)quietInterval
888
+ now:(NSTimeInterval)now
889
+ reason:(RJCaptureHeuristicsReason)reason
890
+ earliestTime:(NSTimeInterval *)earliestTime
891
+ chosenReason:(RJCaptureHeuristicsReason *)chosenReason {
892
+ if (timestamp <= 0) {
893
+ return;
894
+ }
895
+
896
+ NSTimeInterval readyTime = timestamp + quietInterval;
897
+ if (readyTime > now && readyTime > *earliestTime) {
898
+ *earliestTime = readyTime;
899
+ *chosenReason = reason;
900
+ }
901
+ }
902
+
903
+ @end