react-native-theme-reveal 0.0.0-stage → 0.1.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +734 -2
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/baseline-prof.txt +4 -0
  6. package/android/src/main/java/expo/modules/themereveal/CapturePlan.kt +199 -0
  7. package/android/src/main/java/expo/modules/themereveal/CaptureRules.kt +50 -0
  8. package/android/src/main/java/expo/modules/themereveal/GpuSnapshot.kt +367 -0
  9. package/android/src/main/java/expo/modules/themereveal/KeptBitmaps.kt +102 -0
  10. package/android/src/main/java/expo/modules/themereveal/KeptTarget.kt +103 -0
  11. package/android/src/main/java/expo/modules/themereveal/Motion.kt +79 -0
  12. package/android/src/main/java/expo/modules/themereveal/Once.kt +13 -0
  13. package/android/src/main/java/expo/modules/themereveal/RenderThreadReveal.kt +107 -0
  14. package/android/src/main/java/expo/modules/themereveal/RevealOverlayView.kt +192 -0
  15. package/android/src/main/java/expo/modules/themereveal/SecondSnapshotWait.kt +112 -0
  16. package/android/src/main/java/expo/modules/themereveal/ShapePath.kt +47 -0
  17. package/android/src/main/java/expo/modules/themereveal/ThemeRevealModule.kt +904 -0
  18. package/android/src/main/java/expo/modules/themereveal/TransitionArgs.kt +50 -0
  19. package/android/src/main/java/expo/modules/themereveal/WindowSnapshot.kt +536 -0
  20. package/build/ThemeRevealModule.d.ts +11 -0
  21. package/build/ThemeRevealModule.d.ts.map +1 -0
  22. package/build/ThemeRevealModule.js +11 -0
  23. package/build/ThemeRevealModule.js.map +1 -0
  24. package/build/createThemeReveal.d.ts +39 -0
  25. package/build/createThemeReveal.d.ts.map +1 -0
  26. package/build/createThemeReveal.js +186 -0
  27. package/build/createThemeReveal.js.map +1 -0
  28. package/build/index.d.ts +9 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +5 -0
  31. package/build/index.js.map +1 -0
  32. package/build/originFromPress.d.ts +12 -0
  33. package/build/originFromPress.d.ts.map +1 -0
  34. package/build/originFromPress.js +26 -0
  35. package/build/originFromPress.js.map +1 -0
  36. package/build/resolveTransition.d.ts +13 -0
  37. package/build/resolveTransition.d.ts.map +1 -0
  38. package/build/resolveTransition.js +43 -0
  39. package/build/resolveTransition.js.map +1 -0
  40. package/build/store.d.ts +8 -0
  41. package/build/store.d.ts.map +1 -0
  42. package/build/store.js +23 -0
  43. package/build/store.js.map +1 -0
  44. package/build/types.d.ts +31 -0
  45. package/build/types.d.ts.map +1 -0
  46. package/build/types.js +2 -0
  47. package/build/types.js.map +1 -0
  48. package/expo-module.config.json +9 -0
  49. package/ios/Motion.swift +171 -0
  50. package/ios/RevealOverlay.swift +115 -0
  51. package/ios/SVGPath.swift +373 -0
  52. package/ios/ThemeReveal.podspec +26 -0
  53. package/ios/ThemeRevealModule.swift +264 -0
  54. package/mocks/ThemeReveal.js +10 -0
  55. package/package.json +67 -4
  56. package/src/ThemeRevealModule.ts +24 -0
  57. package/src/createThemeReveal.ts +262 -0
  58. package/src/index.ts +14 -0
  59. package/src/originFromPress.ts +40 -0
  60. package/src/resolveTransition.ts +63 -0
  61. package/src/store.ts +30 -0
  62. package/src/types.ts +17 -0
@@ -0,0 +1,904 @@
1
+ package expo.modules.themereveal
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.TimeInterpolator
6
+ import android.animation.ValueAnimator
7
+ import android.app.Activity
8
+ import android.content.ComponentCallbacks2
9
+ import android.content.Context
10
+ import android.content.res.Configuration
11
+ import android.graphics.PointF
12
+ import android.os.Build
13
+ import android.os.Handler
14
+ import android.os.Looper
15
+ import android.os.MessageQueue
16
+ import android.os.SystemClock
17
+ import android.util.Log
18
+ import android.view.Choreographer
19
+ import android.view.View
20
+ import android.view.ViewAnimationUtils
21
+ import android.view.ViewGroup
22
+ import androidx.lifecycle.Lifecycle
23
+ import androidx.lifecycle.LifecycleOwner
24
+ import expo.modules.kotlin.Promise
25
+ import expo.modules.kotlin.functions.Queues
26
+ import expo.modules.kotlin.modules.Module
27
+ import expo.modules.kotlin.modules.ModuleDefinition
28
+ import kotlin.math.roundToInt
29
+
30
+ // Overlay has to stay the size of cover. The React root the origin comes from sits at (rootX, rootY) on screen, the
31
+ // overlay at (screenX, screenY). host is the view whose ViewOverlay holds the overlay, the window root the snapshot
32
+ // shows, in activity's window.
33
+ private class OverlayGeometry(
34
+ val cover: View,
35
+ val rootX: Int,
36
+ val rootY: Int,
37
+ val screenX: Int,
38
+ val screenY: Int,
39
+ val host: ViewGroup,
40
+ val activity: Activity,
41
+ )
42
+
43
+ // Frame-commit callback that release can empty: nothing unregisters one, and the window holds on to it until its
44
+ // next commit.
45
+ private class CommitRelay(var target: (() -> Unit)?) : Runnable {
46
+ override fun run() {
47
+ target?.invoke()
48
+ }
49
+ }
50
+
51
+ // Snapshot from capture(), kept out of the window until prepare() attaches it. root is the window root it shows,
52
+ // generation the prepare generation when the capture started.
53
+ private class HeldCapture(
54
+ val ticket: Int,
55
+ val generation: Int,
56
+ val activity: Activity,
57
+ val root: View?,
58
+ val snapshot: Snapshot,
59
+ val takenAtMs: Long,
60
+ )
61
+
62
+ // capture() whose PixelCopy is still running. A prepare() for the same window sets waiter and gets the snapshot.
63
+ private class PendingCapture(val activity: Activity, val root: View?) {
64
+ var waiter: ((Snapshot?) -> Unit)? = null
65
+ }
66
+
67
+ class ThemeRevealModule : Module() {
68
+ private val mainHandler = Handler(Looper.getMainLooper())
69
+ private var overlay: RevealOverlayView? = null
70
+ private var geometry: OverlayGeometry? = null
71
+ private var started: Promise? = null
72
+ private var generation = 0
73
+ private val failsafe = Runnable { removeOverlay() }
74
+ private var held: HeldCapture? = null
75
+ private var pendingCapture: PendingCapture? = null
76
+ private var captureTicket = 0
77
+ private val captureExpiry = Runnable { voidCapture("expired") }
78
+
79
+ // RenderThread reveal: the wait for its second snapshot, the view showing that snapshot, and the animator on the
80
+ // RenderThread (reveal or inverse reveal).
81
+ private var secondSnapshot: SecondSnapshot? = null
82
+ private var secondView: RevealOverlayView? = null
83
+ private var renderThreadAnimator: Animator? = null
84
+
85
+ // Set in OnDestroy, and again first thing in its main message. From then on this module prepares no kept target.
86
+ @Volatile
87
+ private var destroyed = false
88
+
89
+ // Prepare whose own capture is running (its generation), or -1. A cancel or a newer prepare makes it stale.
90
+ private var captureFor = -1
91
+
92
+ // The kept capture target's prepare: KEEP_TARGET_DELAY_MS after a switch or capture ends, then in main's next
93
+ // idle time, and only if CaptureRules.keepsTargetNow still holds by then.
94
+ private val keepTargetIdle = MessageQueue.IdleHandler {
95
+ // After OnDestroy, this module's state tells nothing about the new module's switch, and appContext can already
96
+ // be gone.
97
+ if (destroyed) return@IdleHandler false
98
+ val activity = appContext.currentActivity
99
+ val now = activity != null && CaptureRules.keepsTargetNow(
100
+ overlay != null,
101
+ pendingCapture != null,
102
+ held != null,
103
+ captureFor == generation,
104
+ isResumed(activity),
105
+ )
106
+ if (now) WindowSnapshot.keepTarget(activity, mainHandler)
107
+ false
108
+ }
109
+ private val keepTargetSoon = Runnable {
110
+ Looper.getMainLooper().queue.removeIdleHandler(keepTargetIdle)
111
+ Looper.getMainLooper().queue.addIdleHandler(keepTargetIdle)
112
+ }
113
+
114
+ // Frees the kept bitmaps when the app is hidden or low on memory, or the screen changes size. Runs on main.
115
+ private val memoryCallbacks = object : ComponentCallbacks2 {
116
+ override fun onTrimMemory(level: Int) {
117
+ WindowSnapshot.kept.onTrimMemory(level)
118
+ WindowSnapshot.target.onTrimMemory(level)
119
+ }
120
+
121
+ override fun onConfigurationChanged(newConfig: Configuration) {
122
+ WindowSnapshot.kept.onScreenSize(newConfig.screenWidthDp, newConfig.screenHeightDp)
123
+ WindowSnapshot.target.onScreenSize(newConfig.screenWidthDp, newConfig.screenHeightDp)
124
+ }
125
+
126
+ @Deprecated("Deprecated in Java")
127
+ override fun onLowMemory() {
128
+ WindowSnapshot.kept.clear()
129
+ WindowSnapshot.target.clear()
130
+ }
131
+ }
132
+
133
+ // Context memoryCallbacks are registered with.
134
+ private var callbacksContext: Context? = null
135
+
136
+ override fun definition() = ModuleDefinition {
137
+ Name("ThemeReveal")
138
+
139
+ AsyncFunction("prepare") { viewTag: Int?, promise: Promise ->
140
+ prepare(viewTag, promise)
141
+ }.runOnQueue(Queues.MAIN)
142
+
143
+ AsyncFunction("start") { transition: Map<String, Any?>, promise: Promise ->
144
+ start(TransitionArgs.from(transition), promise)
145
+ }.runOnQueue(Queues.MAIN)
146
+
147
+ AsyncFunction("capture") { viewTag: Int?, promise: Promise ->
148
+ capture(viewTag, promise)
149
+ }.runOnQueue(Queues.MAIN)
150
+
151
+ AsyncFunction("dropCapture") {
152
+ voidCapture("dropped")
153
+ }.runOnQueue(Queues.MAIN)
154
+
155
+ AsyncFunction("cancel") {
156
+ generation++
157
+ voidCapture("cancel")
158
+ removeOverlay()
159
+ }.runOnQueue(Queues.MAIN)
160
+
161
+ // Runs off main. JS only calls it when the warmUp option is on.
162
+ AsyncFunction("warmUp") { promise: Promise ->
163
+ warmUp(promise)
164
+ }
165
+
166
+ OnCreate {
167
+ callbacksContext = appContext.reactContext?.applicationContext?.also {
168
+ it.registerComponentCallbacks(memoryCallbacks)
169
+ // Size to compare the first change against.
170
+ val config = it.resources.configuration
171
+ mainHandler.post {
172
+ WindowSnapshot.kept.onScreenSize(config.screenWidthDp, config.screenHeightDp)
173
+ WindowSnapshot.target.onScreenSize(config.screenWidthDp, config.screenHeightDp)
174
+ }
175
+ }
176
+ }
177
+
178
+ OnActivityEntersBackground {
179
+ voidCapture("pause")
180
+ }
181
+
182
+ // A prepare refused by a pause runs now. After a trim the target stays hidden until the next snapshot.
183
+ OnActivityEntersForeground {
184
+ scheduleKeepTarget()
185
+ }
186
+
187
+ OnActivityDestroys {
188
+ generation++
189
+ voidCapture("destroy")
190
+ removeOverlay()
191
+ WindowSnapshot.kept.hide()
192
+ WindowSnapshot.target.hide()
193
+ }
194
+
195
+ OnDestroy {
196
+ // Here too, off main, for an idle handler that runs before the main message below.
197
+ destroyed = true
198
+ callbacksContext?.unregisterComponentCallbacks(memoryCallbacks)
199
+ callbacksContext = null
200
+ mainHandler.post {
201
+ destroyed = true
202
+ generation++
203
+ voidCapture("destroy")
204
+ removeOverlay()
205
+ cancelKeepTarget()
206
+ WindowSnapshot.kept.hide()
207
+ WindowSnapshot.target.hide()
208
+ // Next module keeps a target only if its own warm-up runs.
209
+ WindowSnapshot.target.keep = false
210
+ }
211
+ }
212
+ }
213
+
214
+ // Off main: starts the copy thread. Then, on main, prepares the first switch's capture: GPU capture on API 29+,
215
+ // otherwise a kept bitmap of the window's size. Failures go to the log and the promise.
216
+ private fun warmUp(promise: Promise) {
217
+ loadClasses()
218
+ val error = try {
219
+ WindowSnapshot.warmUp()
220
+ null
221
+ } catch (thrown: Exception) {
222
+ Log.w(TAG, "Cannot start the copy thread", thrown)
223
+ thrown
224
+ }
225
+ mainHandler.post {
226
+ WindowSnapshot.kept.keep = true
227
+ WindowSnapshot.target.keep = true
228
+ val activity = appContext.currentActivity
229
+ // A JS reload hid the pool in the old module's OnDestroy. This warm-up comes from a mounted UI.
230
+ if (activity != null && isResumed(activity)) {
231
+ WindowSnapshot.kept.show()
232
+ WindowSnapshot.target.show()
233
+ }
234
+ // Copy thread didn't start, so the first switch's path can't be prepared either.
235
+ if (error == null) {
236
+ WindowSnapshot.prepareFirst(activity, mainHandler)
237
+ // Not right away: the app's first frames after the mount share the RenderThread with it.
238
+ scheduleKeepTarget()
239
+ }
240
+ if (error == null) promise.resolve() else promise.reject("ERR_THEME_REVEAL_WARM_UP", error.message, error)
241
+ }
242
+ }
243
+
244
+ // Off main: loads most of the classes a switch uses, so the first switch doesn't load them on the main thread.
245
+ // Synthetic lambda classes still load on first use. A class in this list must not touch the main looper in its
246
+ // static initializer, and none can fail there: a class whose initializer threw can't be used later either.
247
+ private fun loadClasses() {
248
+ val classes = listOf(
249
+ HeldCapture::class.java,
250
+ PendingCapture::class.java,
251
+ OverlayGeometry::class.java,
252
+ CommitRelay::class.java,
253
+ SecondSnapshot::class.java,
254
+ Snapshot::class.java,
255
+ RevealOverlayView::class.java,
256
+ Once::class.java,
257
+ CaptureRules::class.java,
258
+ CaptureEnd::class.java,
259
+ Motion::class.java,
260
+ ShapePath::class.java,
261
+ RevealPlan::class.java,
262
+ RevealPath::class.java,
263
+ GrowthInterpolator::class.java,
264
+ ShrinkInterpolator::class.java,
265
+ FallbackCounter::class.java,
266
+ SecondSnapshotWait::class.java,
267
+ WaitState::class.java,
268
+ KeptTarget::class.java,
269
+ )
270
+ for (type in classes) {
271
+ try {
272
+ Class.forName(type.name, true, type.classLoader)
273
+ } catch (_: LinkageError) {
274
+ // Failed verification or link: the first use reports it.
275
+ } catch (_: ClassNotFoundException) {
276
+ // Not in this build.
277
+ }
278
+ }
279
+ }
280
+
281
+ // Takes the snapshot now and keeps it out of the window, so the screen stays live while the finger is down.
282
+ private fun capture(viewTag: Int?, promise: Promise) {
283
+ voidCapture("replaced")
284
+ // The prepare would hold up this capture's image on the copy thread.
285
+ cancelKeepTarget()
286
+ val ticket = captureTicket
287
+ val gen = generation
288
+ val activity = appContext.currentActivity
289
+ // An overlay on screen would end up in the snapshot, so the switch captures after it instead.
290
+ if (overlay != null || activity == null || activity.window?.decorView == null || activity.isFinishing ||
291
+ !Motion.canAnimate(Build.VERSION.SDK_INT, isResumed(activity))
292
+ ) {
293
+ promise.resolve(false)
294
+ return
295
+ }
296
+ val root = rootOf(activity, viewTag)
297
+ val pending = PendingCapture(activity, root)
298
+ pendingCapture = pending
299
+ WindowSnapshot.capture(activity, root, mainHandler) { snapshot ->
300
+ val waiter = pending.waiter
301
+ if (waiter != null) {
302
+ promise.resolve(snapshot != null)
303
+ waiter(snapshot)
304
+ return@capture
305
+ }
306
+ if (pendingCapture === pending) pendingCapture = null
307
+ if (snapshot == null) {
308
+ scheduleKeepTarget()
309
+ promise.resolve(false)
310
+ return@capture
311
+ }
312
+ // A prepare that started during the copy (its PixelCopy can run before or after this one) makes this a frame
313
+ // from before that switch.
314
+ if (!CaptureRules.keepResult(ticket, captureTicket, gen, generation, overlay != null) || activity.isFinishing ||
315
+ activity.isDestroyed || !isResumed(activity) || !snapshot.host.isAttachedToWindow
316
+ ) {
317
+ WindowSnapshot.kept.give(snapshot.bitmap)
318
+ debug("dropped a capture result: voided, another switch or no window")
319
+ // The capture could have used the kept target, and no overlay is coming.
320
+ scheduleKeepTarget()
321
+ promise.resolve(false)
322
+ return@capture
323
+ }
324
+ // Upload a PixelCopy bitmap during the press, so the overlay's first frame doesn't have to. A GPU snapshot is
325
+ // already on the GPU.
326
+ snapshot.bitmap.prepareToDraw()
327
+ held = HeldCapture(ticket, gen, activity, root, snapshot, SystemClock.uptimeMillis())
328
+ debug("held a capture: ${WindowSnapshot.pathOf(snapshot.bitmap).js}")
329
+ // A GPU snapshot's texture is imported on the RenderThread at its first draw, i.e. the overlay's first frame.
330
+ // Do it now, during the hold.
331
+ WindowSnapshot.preImport(snapshot.bitmap, mainHandler)
332
+ mainHandler.postDelayed(captureExpiry, CaptureRules.TIMEOUT_MS)
333
+ promise.resolve(true)
334
+ }
335
+ }
336
+
337
+ // Frees the held capture and voids a running one no prepare took. A PixelCopy bitmap goes back to the kept
338
+ // bitmaps, a GPU snapshot gets freed.
339
+ private fun voidCapture(reason: String) {
340
+ captureTicket++
341
+ val running = pendingCapture != null
342
+ pendingCapture = null
343
+ mainHandler.removeCallbacks(captureExpiry)
344
+ // That capture could have used the kept target, and no overlay is coming for it.
345
+ if (running || held != null) scheduleKeepTarget()
346
+ val current = held ?: return
347
+ held = null
348
+ WindowSnapshot.kept.give(current.snapshot.bitmap)
349
+ debug("freed the held capture: $reason")
350
+ }
351
+
352
+ private fun rootOf(activity: Activity, viewTag: Int?): View? {
353
+ val tapped = viewTag?.let { findView(it) }?.takeIf { it.isAttachedToWindow }
354
+ return tapped?.rootView ?: WindowSnapshot.focusedRoot(activity)
355
+ }
356
+
357
+ private fun prepare(viewTag: Int?, promise: Promise) {
358
+ val generationBefore = generation
359
+ val ticket = ++generation
360
+ removeOverlay()
361
+ // This switch's capture is using the copy thread now.
362
+ cancelKeepTarget()
363
+ val activity = appContext.currentActivity
364
+ val window = activity?.window
365
+ val decor = window?.decorView as? ViewGroup
366
+ if (activity == null || window == null || decor == null || activity.isFinishing ||
367
+ !Motion.canAnimate(Build.VERSION.SDK_INT, isResumed(activity))
368
+ ) {
369
+ voidCapture("no window")
370
+ promise.resolve(null)
371
+ return
372
+ }
373
+ val root = rootOf(activity, viewTag)
374
+ val tapped = viewTag?.let { findView(it) }?.takeIf { it.isAttachedToWindow }
375
+ val reactRoot = tapped?.let { WindowSnapshot.reactRootOf(it) } ?: root ?: decor
376
+ // A switch that ends without an overlay still changes the theme (JS applies it), so a capture that started
377
+ // during its copy shows the screen from before. A newer prepare (ticket != generation) handles its own.
378
+ fun fail() {
379
+ if (captureFor == ticket) captureFor = -1
380
+ if (ticket == generation) voidCapture("switch failed")
381
+ // The capture could have used the kept target, and no overlay is coming.
382
+ scheduleKeepTarget()
383
+ promise.resolve(null)
384
+ }
385
+ fun attach(snapshot: Snapshot?) {
386
+ if (captureFor == ticket) captureFor = -1
387
+ if (snapshot == null) {
388
+ fail()
389
+ return
390
+ }
391
+ val bitmap = snapshot.bitmap
392
+ if (ticket != generation || activity.isFinishing || activity.isDestroyed || !isResumed(activity) ||
393
+ !snapshot.host.isAttachedToWindow
394
+ ) {
395
+ WindowSnapshot.kept.give(bitmap)
396
+ fail()
397
+ return
398
+ }
399
+ // Kick off the snapshot's GPU upload now, on the RenderThread, so it doesn't land in the overlay's first
400
+ // frame. A no-op for a held capture, whose upload started at capture time, and for a GPU snapshot, which is
401
+ // already on the GPU.
402
+ bitmap.prepareToDraw()
403
+ val view = RevealOverlayView(activity, bitmap)
404
+ // In the host's overlay, not among its children: adding or removing a child lays out the whole window, and
405
+ // after that layout the traversal made two binder calls to the system (3-4 ms on a Mi 11 Lite) in the overlay's
406
+ // first frame and again in the frame that removes it. The overlay draws above every child and lays nothing out.
407
+ snapshot.host.overlay.add(view)
408
+ view.layout(snapshot.hostX, snapshot.hostY, snapshot.hostX + bitmap.width, snapshot.hostY + bitmap.height)
409
+ val rootAt = WindowSnapshot.locationOf(reactRoot)
410
+ overlay = view
411
+ geometry = OverlayGeometry(
412
+ snapshot.cover,
413
+ rootAt[0],
414
+ rootAt[1],
415
+ snapshot.screenX,
416
+ snapshot.screenY,
417
+ snapshot.host,
418
+ activity,
419
+ )
420
+ mainHandler.postDelayed(failsafe, FAILSAFE_MS)
421
+ // Any capture held or running right now shows the screen from before this switch.
422
+ voidCapture("switch attached")
423
+ // JS applies the theme when this resolves, and that render blocks the JS thread. Resolve in the next frame's
424
+ // callbacks, after React Native's timers for that frame, so the render starts after that frame's JS callbacks
425
+ // instead of holding them up. The overlay draws in that same frame. No frame comes while the screen is off,
426
+ // so a timeout also resolves, whichever fires first.
427
+ val path = WindowSnapshot.pathOf(bitmap).js
428
+ val resolve = Once { promise.resolve(path) }
429
+ lateinit var timeout: Runnable
430
+ val frame = Choreographer.FrameCallback {
431
+ mainHandler.removeCallbacks(timeout)
432
+ resolve.run()
433
+ }
434
+ timeout = Runnable {
435
+ Choreographer.getInstance().removeFrameCallback(frame)
436
+ resolve.run()
437
+ }
438
+ Choreographer.getInstance().postFrameCallback(frame)
439
+ mainHandler.postDelayed(timeout, RESOLVE_TIMEOUT_MS)
440
+ }
441
+ val kept = held
442
+ if (kept != null && CaptureRules.usable(
443
+ kept.ticket,
444
+ captureTicket,
445
+ kept.generation,
446
+ generationBefore,
447
+ kept.activity === activity && kept.root === root,
448
+ kept.snapshot.bitmap.width,
449
+ kept.snapshot.bitmap.height,
450
+ kept.snapshot.cover.width,
451
+ kept.snapshot.cover.height,
452
+ kept.takenAtMs,
453
+ SystemClock.uptimeMillis(),
454
+ )
455
+ ) {
456
+ held = null
457
+ mainHandler.removeCallbacks(captureExpiry)
458
+ debug("prepare uses the held capture")
459
+ attach(kept.snapshot)
460
+ return
461
+ }
462
+ val pending = pendingCapture
463
+ if (pending != null && pending.activity === activity && pending.root === root) {
464
+ pendingCapture = null
465
+ captureFor = ticket
466
+ pending.waiter = ::attach
467
+ debug("prepare waits for the running capture")
468
+ return
469
+ }
470
+ voidCapture("not usable")
471
+ captureFor = ticket
472
+ WindowSnapshot.capture(activity, root, mainHandler, ::attach)
473
+ }
474
+
475
+ private fun start(transition: TransitionArgs, promise: Promise) {
476
+ val view = overlay
477
+ if (view == null) {
478
+ promise.resolve()
479
+ return
480
+ }
481
+ mainHandler.removeCallbacks(failsafe)
482
+ started?.resolve()
483
+ started = promise
484
+ // A start while an earlier start's RenderThread animator is still running on this overlay ends the switch right
485
+ // away. A cancelled RevealAnimator clears its view's reveal clip later, which would break the next animation
486
+ // for a frame.
487
+ if (renderThreadAnimator != null) {
488
+ removeOverlay()
489
+ return
490
+ }
491
+ clearRenderThreadReveal()
492
+ val cover = geometry?.cover
493
+ val durationMs = Motion.durationMs(transition.durationMs)
494
+ // A modal closing between prepare and start detaches the overlay, so resolve right away.
495
+ if (view.parent == null || !view.isAttachedToWindow || cover == null || durationMs == null ||
496
+ !animatorsEnabled() || !Motion.sameSize(view.snapshotWidth, view.snapshotHeight, cover.width, cover.height)
497
+ ) {
498
+ removeOverlay()
499
+ return
500
+ }
501
+ val onEnd = { if (overlay === view) removeOverlay() }
502
+ // Second start on the same overlay: the first one's animation stops here.
503
+ view.still()
504
+ val playing = try {
505
+ play(view, transition, durationMs, onEnd)
506
+ } catch (_: RuntimeException) {
507
+ false
508
+ }
509
+ if (!playing) removeOverlay()
510
+ }
511
+
512
+ private fun play(view: RevealOverlayView, transition: TransitionArgs, durationMs: Long, onEnd: () -> Unit): Boolean {
513
+ when (transition.type) {
514
+ "reveal", "inverseReveal" -> {
515
+ val origin = transition.origin ?: return false
516
+ val space = geometry ?: return false
517
+ val density = view.resources.displayMetrics.density
518
+ val point = Motion.toOverlay(
519
+ (origin.x * density).toFloat(),
520
+ (origin.y * density).toFloat(),
521
+ space.rootX,
522
+ space.rootY,
523
+ space.screenX,
524
+ space.screenY,
525
+ )
526
+ val x = point[0]
527
+ val y = point[1]
528
+ val path = RevealPlan.path(
529
+ transition.type,
530
+ transition.shape != null,
531
+ Build.VERSION.SDK_INT,
532
+ view.snapshotPath,
533
+ animatorsEnabled(),
534
+ fallbacks.off,
535
+ )
536
+ when (path) {
537
+ RevealPath.RENDER_THREAD_REVEAL -> {
538
+ revealOnRenderThread(view, space, x, y, durationMs, onEnd)
539
+ return true
540
+ }
541
+ RevealPath.RENDER_THREAD_INVERSE ->
542
+ if (inverseRevealOnRenderThread(view, x, y, durationMs, onEnd)) return true
543
+ RevealPath.UI_THREAD -> Unit
544
+ }
545
+ view.reveal(shapeOf(transition.shape), x, y, transition.type == "inverseReveal", durationMs, onEnd)
546
+ }
547
+ "wipe" -> view.wipe(wipeDirectionOf(transition.direction) ?: return false, durationMs, onEnd)
548
+ "crossfade" -> view.crossfade(durationMs, onEnd)
549
+ else -> return false
550
+ }
551
+ return true
552
+ }
553
+
554
+ // Circle reveal on the RenderThread. The overlay stays still while the next frame draws React Native's mount of the
555
+ // new theme. In the frame after that frame's commit, a second GPU snapshot of the same window(s), minus the overlay,
556
+ // goes into a second overlay view above the first, and a circular reveal clip grows on it. If that fails, the
557
+ // UI-thread reveal runs instead (SecondSnapshotWait).
558
+ private fun revealOnRenderThread(
559
+ view: RevealOverlayView,
560
+ space: OverlayGeometry,
561
+ x: Float,
562
+ y: Float,
563
+ durationMs: Long,
564
+ onEnd: () -> Unit,
565
+ ) {
566
+ val run = SecondSnapshot(view, space, x, y, durationMs, onEnd)
567
+ secondSnapshot = run
568
+ run.begin()
569
+ }
570
+
571
+ // Inverse circle reveal on the RenderThread: a circular reveal clip on the overlay itself, from the cover radius
572
+ // down to 0. No second snapshot. False if it can't start, and then the caller runs the UI-thread one.
573
+ private fun inverseRevealOnRenderThread(
574
+ view: RevealOverlayView,
575
+ x: Float,
576
+ y: Float,
577
+ durationMs: Long,
578
+ onEnd: () -> Unit,
579
+ ): Boolean {
580
+ val radii = RevealPlan.circleRadii(x, y, view.snapshotWidth, view.snapshotHeight, view.startHeightPx)
581
+ val r0 = radii[0]
582
+ val r1 = radii[1]
583
+ if (!RevealPlan.radiiUsable(r0, r1)) return false
584
+ try {
585
+ startCircularReveal(view, x, y, r1, 0f, ShrinkInterpolator(r0, r1), durationMs, onEnd)
586
+ } catch (_: RuntimeException) {
587
+ clearRenderThreadReveal()
588
+ return false
589
+ }
590
+ debug("inverse reveal runs on the RenderThread")
591
+ return true
592
+ }
593
+
594
+ private fun startCircularReveal(
595
+ view: View,
596
+ x: Float,
597
+ y: Float,
598
+ from: Float,
599
+ to: Float,
600
+ curve: TimeInterpolator,
601
+ durationMs: Long,
602
+ onEnd: () -> Unit,
603
+ ) {
604
+ val animator = ViewAnimationUtils.createCircularReveal(view, x.roundToInt(), y.roundToInt(), from, to)
605
+ animator.duration = durationMs
606
+ animator.interpolator = curve
607
+ animator.addListener(object : AnimatorListenerAdapter() {
608
+ override fun onAnimationEnd(animation: Animator) = onEnd()
609
+ })
610
+ renderThreadAnimator = animator
611
+ animator.start()
612
+ // A RenderThread animator doesn't advance while the window isn't drawing, so the overlay is removed then at the
613
+ // latest.
614
+ mainHandler.removeCallbacks(failsafe)
615
+ mainHandler.postDelayed(failsafe, durationMs + FAILSAFE_MS)
616
+ }
617
+
618
+ // Shows the second snapshot above the overlay and grows a circular reveal clip on it, on the RenderThread. Null if it
619
+ // runs, otherwise the reason it doesn't. Frees the snapshot if it doesn't use it.
620
+ private fun revealSecond(
621
+ view: RevealOverlayView,
622
+ space: OverlayGeometry,
623
+ x: Float,
624
+ y: Float,
625
+ durationMs: Long,
626
+ onEnd: () -> Unit,
627
+ snapshot: Snapshot,
628
+ ): String? {
629
+ val bitmap = snapshot.bitmap
630
+ val matches = RevealPlan.secondMatches(
631
+ overlay === view,
632
+ view.isAttachedToWindow,
633
+ snapshot.host === space.host,
634
+ WindowSnapshot.pathOf(bitmap) == CapturePath.GPU,
635
+ bitmap.width,
636
+ bitmap.height,
637
+ view.snapshotWidth,
638
+ view.snapshotHeight,
639
+ snapshot.hostX == view.left && snapshot.hostY == view.top,
640
+ )
641
+ if (!matches) {
642
+ WindowSnapshot.kept.give(bitmap)
643
+ return "the second snapshot doesn't match the first"
644
+ }
645
+ val radii = RevealPlan.circleRadii(x, y, view.snapshotWidth, view.snapshotHeight, view.startHeightPx)
646
+ val r0 = radii[0]
647
+ val r1 = radii[1]
648
+ if (!RevealPlan.radiiUsable(r0, r1)) {
649
+ WindowSnapshot.kept.give(bitmap)
650
+ return "radii $r0 to $r1"
651
+ }
652
+ val next = RevealOverlayView(view.context, bitmap)
653
+ // Second view owns the bitmap from here on: clearRenderThreadReveal frees it.
654
+ secondView = next
655
+ try {
656
+ space.host.overlay.add(next)
657
+ next.layout(view.left, view.top, view.right, view.bottom)
658
+ startCircularReveal(next, x, y, r0, r1, GrowthInterpolator(r0, r1), durationMs, onEnd)
659
+ } catch (_: RuntimeException) {
660
+ clearRenderThreadReveal()
661
+ return "cannot start the circular reveal"
662
+ }
663
+ if (Log.isLoggable(TAG, Log.DEBUG)) probe(view, next, x, y)
664
+ return null
665
+ }
666
+
667
+ // Debug only, slow: reads one pixel of both snapshots away from the press, to show the second one has the new theme.
668
+ private fun probe(old: RevealOverlayView, next: RevealOverlayView, x: Float, y: Float) {
669
+ val w = old.snapshotWidth
670
+ val h = old.snapshotHeight
671
+ val px = (if (x < w / 2f) w * 0.9f else w * 0.1f).toInt()
672
+ val py = (if (y < h / 2f) h * 0.9f else h * 0.1f).toInt()
673
+ val before = old.pixelAt(px, py)
674
+ val after = next.pixelAt(px, py)
675
+ val hex = { color: Int? -> color?.let { "#%08x".format(it) } ?: "none" }
676
+ Log.d(TAG, "second snapshot probe at ($px, $py): old ${hex(before)}, new ${hex(after)}, differs ${before != after}")
677
+ }
678
+
679
+ // Stops the RenderThread reveal: the wait for the second snapshot (its callbacks and prepared target), the animator,
680
+ // and the second view with its bitmap.
681
+ private fun clearRenderThreadReveal() {
682
+ secondSnapshot?.wait?.cancel()
683
+ secondSnapshot = null
684
+ renderThreadAnimator?.let {
685
+ it.removeAllListeners()
686
+ it.cancel()
687
+ }
688
+ renderThreadAnimator = null
689
+ val next = secondView ?: return
690
+ secondView = null
691
+ (next.parent as? ViewGroup)?.removeView(next)
692
+ next.release(WindowSnapshot.kept::give)
693
+ }
694
+
695
+ // One RenderThread reveal's wait for its second snapshot (SecondSnapshotWait): the frame callbacks, the commit
696
+ // callback, the timeout and the target prepared for the capture. Main thread only.
697
+ private inner class SecondSnapshot(
698
+ private val view: RevealOverlayView,
699
+ private val space: OverlayGeometry,
700
+ private val x: Float,
701
+ private val y: Float,
702
+ private val durationMs: Long,
703
+ private val onEnd: () -> Unit,
704
+ ) : SecondSnapshotSteps<Snapshot> {
705
+ val wait = SecondSnapshotWait(this)
706
+ private val startedAt = SystemClock.uptimeMillis()
707
+
708
+ // Capture's target, from when it's ready until the capture takes it.
709
+ private var output: GpuOutput? = null
710
+ private val frame = Choreographer.FrameCallback {
711
+ val ok = windowOk()
712
+ // That way this frame's traversal always draws and its commit arrives. Without it, a theme mount that a frame
713
+ // drew before start() leaves this frame nothing to draw, and the wait runs into its timeout.
714
+ if (ok) view.invalidate()
715
+ wait.frame(ok)
716
+ }
717
+
718
+ // Nothing unregisters a frame-commit callback. release() empties the relay so it doesn't keep this wait alive.
719
+ private val commit = CommitRelay {
720
+ if (wait.state == WaitState.WAITING_FOR_COMMIT) debug("frame commit after ${sinceStart()} ms")
721
+ wait.commit(windowOk())
722
+ }
723
+
724
+ // React Native's frame callbacks for this frame (mount dispatch, timers, the JS rAF tick) were posted before this
725
+ // one, so they run first.
726
+ private val captureFrame = Choreographer.FrameCallback { wait.captureFrame(windowOk()) }
727
+ private val timeout = Runnable { wait.timeout() }
728
+
729
+ // Starts the wait. The capture's target is made ready now, while JS renders the theme.
730
+ fun begin() {
731
+ WindowSnapshot.prepareOutput(view.snapshotWidth, view.snapshotHeight, mainHandler) { prepared, ms ->
732
+ val used = wait.beforeCapture
733
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
734
+ Log.d(
735
+ TAG,
736
+ "prepared the second snapshot's target after %d ms, %.1f ms on the copy thread: %b, used %b".format(
737
+ sinceStart(),
738
+ ms,
739
+ prepared != null,
740
+ used && prepared != null,
741
+ ),
742
+ )
743
+ }
744
+ if (used) output = prepared else WindowSnapshot.releaseOutput(prepared)
745
+ }
746
+ Choreographer.getInstance().postFrameCallback(frame)
747
+ mainHandler.postDelayed(timeout, SecondSnapshotWait.TIMEOUT_MS)
748
+ }
749
+
750
+ private fun sinceStart() = SystemClock.uptimeMillis() - startedAt
751
+
752
+ private fun windowOk(): Boolean {
753
+ val activity = space.activity
754
+ return overlay === view && !activity.isFinishing && !activity.isDestroyed && space.host.isAttachedToWindow &&
755
+ view.isAttachedToWindow
756
+ }
757
+
758
+ override fun registerCommit(): Boolean {
759
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false
760
+ return try {
761
+ // Frame callback invalidated the overlay, so this frame's traversal draws and its commit arrives.
762
+ space.host.viewTreeObserver.registerFrameCommitCallback(commit)
763
+ true
764
+ } catch (_: IllegalStateException) {
765
+ // A dead ViewTreeObserver.
766
+ false
767
+ }
768
+ }
769
+
770
+ override fun postCaptureFrame() = Choreographer.getInstance().postFrameCallback(captureFrame)
771
+
772
+ override fun capture() {
773
+ val prepared = output
774
+ output = null
775
+ val start = System.nanoTime()
776
+ // Window without the overlay: the capture hides it only while recording the window, on this thread.
777
+ WindowSnapshot.captureSecond(space.activity, space.host, mainHandler, view, prepared) { snapshot ->
778
+ debug("second snapshot result after ${sinceStart()} ms: ${snapshot != null}")
779
+ wait.result(snapshot)
780
+ }
781
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
782
+ Log.d(
783
+ TAG,
784
+ "second snapshot frame after %d ms, capture %.1f ms on main, prepared target %b".format(
785
+ sinceStart(),
786
+ (System.nanoTime() - start) / 1e6,
787
+ prepared != null,
788
+ ),
789
+ )
790
+ }
791
+ }
792
+
793
+ override fun reveal(bitmap: Snapshot): String? {
794
+ val failure = revealSecond(view, space, x, y, durationMs, onEnd, bitmap)
795
+ if (failure == null) fallbacks.revealed()
796
+ return failure
797
+ }
798
+
799
+ override fun fallBack(reason: String) {
800
+ debug("RenderThread reveal falls back to the UI-thread reveal: $reason")
801
+ if (overlay !== view) return
802
+ // A modal that closed during the wait took the overlay out of the window, so it goes now, same as in start().
803
+ if (!view.isAttachedToWindow || !space.host.isAttachedToWindow) {
804
+ removeOverlay()
805
+ return
806
+ }
807
+ if (RevealPlan.countsFallBack(windowOk(), isResumed(space.activity))) {
808
+ fallbacks.fellBack()
809
+ if (fallbacks.off) debug("the RenderThread reveal is off for this process: too many fall backs in a row")
810
+ }
811
+ view.reveal(ShapePath.circle(), x, y, false, durationMs, onEnd)
812
+ }
813
+
814
+ override fun release() {
815
+ if (secondSnapshot === this) secondSnapshot = null
816
+ commit.target = null
817
+ Choreographer.getInstance().removeFrameCallback(frame)
818
+ Choreographer.getInstance().removeFrameCallback(captureFrame)
819
+ mainHandler.removeCallbacks(timeout)
820
+ WindowSnapshot.releaseOutput(output)
821
+ output = null
822
+ }
823
+
824
+ override fun free(bitmap: Snapshot) = WindowSnapshot.kept.give(bitmap.bitmap)
825
+ }
826
+
827
+ private fun removeOverlay() {
828
+ mainHandler.removeCallbacks(failsafe)
829
+ clearRenderThreadReveal()
830
+ started?.resolve()
831
+ started = null
832
+ val view = overlay ?: return
833
+ overlay = null
834
+ geometry = null
835
+ (view.parent as? ViewGroup)?.removeView(view)
836
+ view.release(WindowSnapshot.kept::give)
837
+ scheduleKeepTarget()
838
+ }
839
+
840
+ // Prepares the capture target for the next switch's first snapshot (keepTargetIdle), KEEP_TARGET_DELAY_MS from now,
841
+ // so the frames right after a switch or mount don't share the RenderThread with it. A later call pushes it back.
842
+ private fun scheduleKeepTarget() {
843
+ if (destroyed) return
844
+ mainHandler.removeCallbacks(keepTargetSoon)
845
+ mainHandler.postDelayed(keepTargetSoon, KEEP_TARGET_DELAY_MS)
846
+ }
847
+
848
+ private fun cancelKeepTarget() {
849
+ mainHandler.removeCallbacks(keepTargetSoon)
850
+ Looper.getMainLooper().queue.removeIdleHandler(keepTargetIdle)
851
+ }
852
+
853
+ private fun debug(message: String) {
854
+ if (Log.isLoggable(TAG, Log.DEBUG)) Log.d(TAG, message)
855
+ }
856
+
857
+ private fun findView(viewTag: Int): View? = try {
858
+ appContext.findView<View>(viewTag)
859
+ } catch (_: RuntimeException) {
860
+ null
861
+ }
862
+
863
+ private fun isResumed(activity: Activity): Boolean =
864
+ (activity as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
865
+
866
+ private fun animatorsEnabled(): Boolean =
867
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.O || ValueAnimator.areAnimatorsEnabled()
868
+
869
+ private fun shapeOf(record: ShapeArgs?): ShapePath {
870
+ if (record == null) return ShapePath.circle()
871
+ return try {
872
+ ShapePath.parse(
873
+ record.path,
874
+ PointF(record.anchor.x.toFloat(), record.anchor.y.toFloat()),
875
+ record.inscribedRadius.toFloat(),
876
+ )
877
+ } catch (error: RuntimeException) {
878
+ Log.w(TAG, "Cannot parse the shape path \"${record.path}\", using the circle", error)
879
+ ShapePath.circle()
880
+ }
881
+ }
882
+
883
+ private fun wipeDirectionOf(direction: String?): WipeDirection? = when (direction) {
884
+ "up" -> WipeDirection.UP
885
+ "down" -> WipeDirection.DOWN
886
+ "left" -> WipeDirection.LEFT
887
+ "right" -> WipeDirection.RIGHT
888
+ else -> null
889
+ }
890
+
891
+ private companion object {
892
+ const val FAILSAFE_MS = 1000L
893
+
894
+ // Longest prepare waits for the frame callback before resolving.
895
+ const val RESOLVE_TIMEOUT_MS = 100L
896
+
897
+ // How long the kept capture target waits after a switch, capture or mount before its prepare.
898
+ const val KEEP_TARGET_DELAY_MS = 500L
899
+
900
+ // The RenderThread reveal's fallbacks in a row, process-wide (RevealPlan.path's secondSnapshotOff).
901
+ val fallbacks = FallbackCounter(RevealPlan.FALLBACKS_TO_OFF)
902
+ const val TAG = "ThemeReveal"
903
+ }
904
+ }