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,50 @@
1
+ package expo.modules.themereveal
2
+
3
+ // The start argument, read from the JS object by hand. Expo would convert a Record with Kotlin reflection, and its
4
+ // first conversion blocked the main thread on the first start (85-102 ms on a Mi 11 Lite, 18-27 ms on a Pixel 8
5
+ // Pro). A missing, null or wrong-typed field gets the default the Records had, so start takes the same path for it.
6
+ // A null or undefined inside origin or shape never gets here, because Expo's conversion of the nested object
7
+ // rejects start before this runs. resolveTransition in JS keeps such values out of start.
8
+ internal data class PointArgs(val x: Double, val y: Double) {
9
+ companion object {
10
+ fun from(value: Any?): PointArgs? {
11
+ val map = value as? Map<*, *> ?: return null
12
+ return PointArgs(map.number("x"), map.number("y"))
13
+ }
14
+ }
15
+ }
16
+
17
+ internal data class ShapeArgs(val path: String, val anchor: PointArgs, val inscribedRadius: Double) {
18
+ companion object {
19
+ fun from(value: Any?): ShapeArgs? {
20
+ val map = value as? Map<*, *> ?: return null
21
+ return ShapeArgs(
22
+ map.string("path") ?: "",
23
+ PointArgs.from(map["anchor"]) ?: PointArgs(0.0, 0.0),
24
+ map.number("inscribedRadius"),
25
+ )
26
+ }
27
+ }
28
+ }
29
+
30
+ internal data class TransitionArgs(
31
+ val type: String,
32
+ val durationMs: Double,
33
+ val origin: PointArgs?,
34
+ val shape: ShapeArgs?,
35
+ val direction: String?,
36
+ ) {
37
+ companion object {
38
+ fun from(map: Map<*, *>): TransitionArgs = TransitionArgs(
39
+ type = map.string("type") ?: "",
40
+ durationMs = map.number("durationMs"),
41
+ origin = PointArgs.from(map["origin"]),
42
+ shape = ShapeArgs.from(map["shape"]),
43
+ direction = map.string("direction"),
44
+ )
45
+ }
46
+ }
47
+
48
+ private fun Map<*, *>.number(key: String): Double = (this[key] as? Number)?.toDouble() ?: 0.0
49
+
50
+ private fun Map<*, *>.string(key: String): String? = this[key] as? String
@@ -0,0 +1,536 @@
1
+ package expo.modules.themereveal
2
+
3
+ import android.app.Activity
4
+ import android.content.pm.ActivityInfo
5
+ import android.graphics.Bitmap
6
+ import android.graphics.Canvas
7
+ import android.graphics.Color
8
+ import android.graphics.Rect
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.HandlerThread
12
+ import android.os.Looper
13
+ import android.os.Process
14
+ import android.util.Log
15
+ import android.view.PixelCopy
16
+ import android.view.View
17
+ import android.view.ViewGroup
18
+ import android.view.Window
19
+ import android.view.WindowManager
20
+ import com.facebook.react.uimanager.RootView
21
+ import com.facebook.react.views.modal.ReactModalHostView
22
+
23
+ // Overlay goes into host's overlay at (hostX, hostY), bitmap-sized. The bitmap covers cover (the activity decor
24
+ // view), whose top-left corner is at (screenX, screenY) on screen.
25
+ internal class Snapshot(
26
+ val bitmap: Bitmap,
27
+ val host: ViewGroup,
28
+ val cover: View,
29
+ val hostX: Int,
30
+ val hostY: Int,
31
+ val screenX: Int,
32
+ val screenY: Int,
33
+ )
34
+
35
+ internal object WindowSnapshot {
36
+ private const val TAG = "ThemeReveal"
37
+
38
+ // Activity-window bitmaps for the next PixelCopy capture. A GPU snapshot is a hardware bitmap, and those are
39
+ // immutable: PixelCopy can't copy into it, so it isn't kept. Main thread only.
40
+ val kept = KeptBitmaps<Bitmap>(1, { it.width }, { it.height }, { it.isRecycled }, { it.isMutable }) { it.recycle() }
41
+
42
+ fun capture(activity: Activity, root: View?, handler: Handler, onResult: (Snapshot?) -> Unit) =
43
+ capture(activity, root, handler, null, null, onResult)
44
+
45
+ // The RenderThread reveal's second snapshot, API 29+: GPU capture only, without hide (the overlay), into prepared
46
+ // if it's not null. Takes prepared and frees it, even when it doesn't use it. No PixelCopy fallback, since a
47
+ // PixelCopy would show the overlay. A failure gives null and doesn't turn off the GPU path: the next switch's first
48
+ // snapshot tests that path under its own rules.
49
+ fun captureSecond(
50
+ activity: Activity,
51
+ root: View?,
52
+ handler: Handler,
53
+ hide: View,
54
+ prepared: GpuOutput?,
55
+ onResult: (Snapshot?) -> Unit,
56
+ ) = capture(activity, root, handler, hide, prepared, onResult)
57
+
58
+ // hide: see captureWindows.
59
+ private fun capture(
60
+ activity: Activity,
61
+ root: View?,
62
+ handler: Handler,
63
+ hide: View?,
64
+ prepared: GpuOutput?,
65
+ onResult: (Snapshot?) -> Unit,
66
+ ) {
67
+ val window = activity.window
68
+ val decor = window?.decorView as? ViewGroup
69
+ if (window == null || decor == null) {
70
+ releaseOutput(prepared)
71
+ onResult(null)
72
+ return
73
+ }
74
+ val screen = locationOf(decor)
75
+ if (root == null || root === decor) {
76
+ val pixelCopy = { done: (Bitmap?) -> Unit -> capture(window, decor.width, decor.height, handler, true, done) }
77
+ val draw = { canvas: Canvas -> GpuSnapshot.drawWindow(canvas, decor) }
78
+ captureWindows(activity, decor, listOf(window), handler, draw, pixelCopy, hide, prepared) {
79
+ onResult(it?.let { bitmap -> Snapshot(bitmap, decor, decor, 0, 0, screen[0], screen[1]) })
80
+ }
81
+ return
82
+ }
83
+ val dialog = findModalWindow(decor) { it.peekDecorView() === root }
84
+ val dialogDecor = dialog?.peekDecorView() as? ViewGroup
85
+ if (dialog == null || dialogDecor == null) {
86
+ releaseOutput(prepared)
87
+ onResult(null)
88
+ return
89
+ }
90
+ val at = locationOf(dialogDecor)
91
+ val dialogX = at[0] - screen[0]
92
+ val dialogY = at[1] - screen[1]
93
+ val frame = Motion.dialogFrame(dialogX, dialogY, dialogDecor.width, dialogDecor.height, decor.width, decor.height)
94
+ val attributes = dialog.attributes
95
+ val dim = if (attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND != 0) {
96
+ Motion.dimAlpha(attributes.dimAmount)
97
+ } else {
98
+ 0
99
+ }
100
+ // The GPU draws both windows and the dim into one picture, like compose does with the two PixelCopy bitmaps.
101
+ val draw = { canvas: Canvas ->
102
+ GpuSnapshot.drawWindow(canvas, decor)
103
+ if (dim > 0) canvas.drawColor(Color.argb(dim, 0, 0, 0))
104
+ if (!frame.isEmpty) {
105
+ val count = canvas.save()
106
+ canvas.clipRect(frame.left, frame.top, frame.right, frame.bottom)
107
+ canvas.translate(dialogX.toFloat(), dialogY.toFloat())
108
+ GpuSnapshot.drawWindow(canvas, dialogDecor)
109
+ canvas.restoreToCount(count)
110
+ }
111
+ }
112
+ val pixelCopy = { done: (Bitmap?) -> Unit ->
113
+ capturePair(window, decor.width, decor.height, dialog, dialogDecor.width, dialogDecor.height, handler) { back, front ->
114
+ if (back == null || front == null) {
115
+ back?.let(kept::give)
116
+ front?.recycle()
117
+ done(null)
118
+ return@capturePair
119
+ }
120
+ val composed = try {
121
+ compose(back, front, frame, dialogX, dialogY, dim)
122
+ true
123
+ } catch (_: RuntimeException) {
124
+ false
125
+ } finally {
126
+ front.recycle()
127
+ }
128
+ if (!composed) {
129
+ kept.give(back)
130
+ done(null)
131
+ return@capturePair
132
+ }
133
+ done(back)
134
+ }
135
+ }
136
+ captureWindows(activity, decor, listOf(window, dialog), handler, draw, pixelCopy, hide, prepared) {
137
+ onResult(it?.let { bitmap -> Snapshot(bitmap, dialogDecor, decor, -dialogX, -dialogY, screen[0], screen[1]) })
138
+ }
139
+ }
140
+
141
+ private val mainHandler = Handler(Looper.getMainLooper())
142
+
143
+ // Capture target kept for the activity window's next first snapshot (keepTarget). Main thread only. A target a
144
+ // capture can't use is freed in a later main message, off that capture's path.
145
+ val target = KeptTarget<GpuOutput>(
146
+ { releaseOutput(it, KEPT_TARGET) },
147
+ { output -> mainHandler.post { releaseOutput(output, KEPT_TARGET) } },
148
+ )
149
+
150
+ // Set when a GPU capture timed out or threw: from then on the process captures with PixelCopy. Main thread only.
151
+ var gpuOff = false
152
+ private set
153
+
154
+ // Snapshot of windows, decor-sized, drawn on the GPU on API 29+. Read back with PixelCopy on API 26-28, for a wide
155
+ // color window, and when the GPU capture fails. With hide set (the second snapshot): the draw leaves that view
156
+ // out, null instead of PixelCopy, and a failure leaves gpuOff alone. prepared: see captureSecond.
157
+ private fun captureWindows(
158
+ activity: Activity,
159
+ decor: View,
160
+ windows: List<Window>,
161
+ handler: Handler,
162
+ draw: (Canvas) -> Unit,
163
+ pixelCopy: ((Bitmap?) -> Unit) -> Unit,
164
+ hide: View?,
165
+ prepared: GpuOutput?,
166
+ onResult: (Bitmap?) -> Unit,
167
+ ) {
168
+ val gpuOnly = hide != null
169
+ val noCopy = { done: (Bitmap?) -> Unit -> done(null) }
170
+ val fallback = if (gpuOnly) noCopy else pixelCopy
171
+ val api = Build.VERSION.SDK_INT
172
+ if (api >= Build.VERSION_CODES.Q) {
173
+ val decors = windows.mapNotNull { it.peekDecorView() }
174
+ val defaultColor = windows.all { it.colorMode == ActivityInfo.COLOR_MODE_DEFAULT }
175
+ val accelerated = decors.all { it.isHardwareAccelerated }
176
+ // A decor is dirty while a view change waits for the window's next frame. Read it before the draw clears it.
177
+ val dirty = decors.filter { it.isDirty }
178
+ val pending = dirty.isNotEmpty()
179
+ val forceDark = pending && GpuSnapshot.forceDarkCanApply(activity)
180
+ val light = if (CapturePlan.path(api, defaultColor, accelerated, forceDark, gpuOff) == CapturePath.GPU) {
181
+ GpuSnapshot.lightOf(activity, decor)
182
+ } else {
183
+ null
184
+ }
185
+ if (light != null && decor.width > 0 && decor.height > 0 && !preCreates.skipsGpu()) {
186
+ if (pending && Log.isLoggable(TAG, Log.DEBUG)) {
187
+ Log.d(TAG, "a view change waits for its frame in ${dirty.size} window(s)")
188
+ }
189
+ // Image arrives on the copy thread, where a warm-up pre-create can hold it back past the timeout.
190
+ val busyAtStart = preCreates.busy
191
+ val countAtStart = preCreates.count
192
+ // The view gets hidden inside the draw, after the dirty read above: hiding it marks its window dirty, which
193
+ // would read as a view change under force dark.
194
+ val record = if (hide == null) draw else drawWithout(hide, draw)
195
+ // A first snapshot draws into the kept target if it has the decor's size.
196
+ val output = prepared ?: if (gpuOnly) null else target.take(decor.width, decor.height)
197
+ if (output == null && !gpuOnly && target.preparing && Log.isLoggable(TAG, Log.DEBUG)) {
198
+ Log.d(TAG, "a snapshot starts while the kept target is made: its image waits behind that prepare")
199
+ }
200
+ GpuSnapshot.capture(decor.width, decor.height, light, dirty, handler, copyHandler, record, output) {
201
+ bitmap, failure ->
202
+ if (bitmap != null) {
203
+ onResult(bitmap)
204
+ return@capture
205
+ }
206
+ if (!gpuOnly && failure != null && preCreates.turnsGpuOff(failure, busyAtStart, countAtStart)) {
207
+ gpuOff = true
208
+ target.clear()
209
+ }
210
+ fallback(onResult)
211
+ }
212
+ return
213
+ }
214
+ }
215
+ releaseOutput(prepared)
216
+ fallback(onResult)
217
+ }
218
+
219
+ // draw with view hidden, on the UI thread. The window re-records the view in its next frame.
220
+ private fun drawWithout(view: View, draw: (Canvas) -> Unit): (Canvas) -> Unit = { canvas ->
221
+ view.visibility = View.INVISIBLE
222
+ try {
223
+ draw(canvas)
224
+ } finally {
225
+ view.visibility = View.VISIBLE
226
+ }
227
+ }
228
+
229
+ // Gets a GPU capture target of width x height ready on the copy thread (GpuSnapshot.prepareOutput). onReady gets it
230
+ // on handler, with the ms it took on the copy thread, or null if it failed. The caller frees it with releaseOutput or
231
+ // hands it to captureSecond. Main thread.
232
+ fun prepareOutput(width: Int, height: Int, handler: Handler, onReady: (GpuOutput?, Double) -> Unit) {
233
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || width <= 0 || height <= 0) {
234
+ onReady(null, 0.0)
235
+ return
236
+ }
237
+ // Holds the copy thread like a warm-up pre-create.
238
+ preCreates.started()
239
+ copyHandler.post {
240
+ val start = System.nanoTime()
241
+ val output = try {
242
+ GpuSnapshot.prepareOutput(width, height, copyHandler)
243
+ } catch (_: RuntimeException) {
244
+ null
245
+ } catch (_: OutOfMemoryError) {
246
+ // HardwareRenderer throws it when HWUI can't make its native proxy.
247
+ null
248
+ }
249
+ val ms = (System.nanoTime() - start) / 1e6
250
+ handler.post {
251
+ preCreates.finished()
252
+ onReady(output, ms)
253
+ }
254
+ }
255
+ }
256
+
257
+ // Main thread. Prepares the target the activity window's next first snapshot draws into, if KeptTarget wants one:
258
+ // a window of known size whose capture takes the GPU path, with none kept or in flight.
259
+ fun keepTarget(activity: Activity?, handler: Handler) {
260
+ val decor = activity?.window?.peekDecorView() ?: return
261
+ val width = decor.width
262
+ val height = decor.height
263
+ val gpuOn = warmUpOf(activity) == WarmUp.GPU
264
+ // A target of the old size, or of a window that left the GPU path, goes now and a new one replaces it.
265
+ target.drop(width, height, gpuOn)
266
+ if (!target.wants(width, height, gpuOn)) return
267
+ val ticket = target.started()
268
+ prepareOutput(width, height, handler) { output, ms ->
269
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
270
+ val made = output != null
271
+ Log.d(TAG, "kept a capture target of ${width}x$height in %.1f ms on the copy thread: %b".format(ms, made))
272
+ }
273
+ target.ready(ticket, output, width, height)
274
+ }
275
+ }
276
+
277
+ // Frees a target from prepareOutput. Main thread.
278
+ fun releaseOutput(prepared: GpuOutput?, what: String = "an unused second-snapshot target") {
279
+ if (prepared != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) prepared.release(what)
280
+ }
281
+
282
+ private const val KEPT_TARGET = "the kept target"
283
+
284
+ // Pre-imports a held GPU snapshot's texture on the copy thread (GpuSnapshot.preImport). Failures are ignored: the
285
+ // overlay's first frame imports it as before. Main thread: handler is the main thread's.
286
+ fun preImport(bitmap: Bitmap, handler: Handler) {
287
+ val api = Build.VERSION.SDK_INT
288
+ // Same API check again, in a form lint understands.
289
+ if (api < Build.VERSION_CODES.Q || bitmap.isRecycled || !RevealPlan.preImports(api, pathOf(bitmap))) return
290
+ val node = try {
291
+ GpuSnapshot.preImportNode(bitmap)
292
+ } catch (_: RuntimeException) {
293
+ return
294
+ }
295
+ // Holds the copy thread like a warm-up pre-create.
296
+ preCreates.started()
297
+ copyHandler.post {
298
+ val start = System.nanoTime()
299
+ try {
300
+ GpuSnapshot.preImport(node)
301
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
302
+ Log.d(TAG, "pre-imported the held snapshot in %.1f ms".format((System.nanoTime() - start) / 1e6))
303
+ }
304
+ } catch (_: RuntimeException) {
305
+ // Ignored.
306
+ } catch (_: OutOfMemoryError) {
307
+ // HardwareRenderer throws it when HWUI can't make its native proxy.
308
+ } finally {
309
+ handler.post { preCreates.finished() }
310
+ }
311
+ }
312
+ }
313
+
314
+ // The path that took a snapshot: a GPU capture gives a hardware bitmap.
315
+ fun pathOf(bitmap: Bitmap): CapturePath =
316
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
317
+ CapturePath.GPU
318
+ } else {
319
+ CapturePath.PIXEL_COPY
320
+ }
321
+
322
+ // Focused window root: the activity decor view, or a React Native modal's decor view.
323
+ fun focusedRoot(activity: Activity): View? {
324
+ val decor = activity.window?.decorView as? ViewGroup ?: return null
325
+ if (decor.hasWindowFocus()) return decor
326
+ return findModalWindow(decor) { it.peekDecorView()?.hasWindowFocus() == true }?.peekDecorView() ?: decor
327
+ }
328
+
329
+ // React root that JS measures pageX and pageY against.
330
+ fun reactRootOf(view: View): View {
331
+ var current: View? = view
332
+ while (current != null) {
333
+ if (current is RootView) return current
334
+ current = current.parent as? View
335
+ }
336
+ return view.rootView
337
+ }
338
+
339
+ fun locationOf(view: View): IntArray = IntArray(2).also { view.getLocationOnScreen(it) }
340
+
341
+ // pooled: the bitmap comes from kept and goes back there (the activity window). A dialog window's bitmap doesn't.
342
+ fun capture(window: Window, width: Int, height: Int, handler: Handler, pooled: Boolean, onResult: (Bitmap?) -> Unit) {
343
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || width <= 0 || height <= 0) {
344
+ onResult(null)
345
+ return
346
+ }
347
+ val bitmap = (if (pooled) kept.take(width, height) else null) ?: try {
348
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
349
+ } catch (_: OutOfMemoryError) {
350
+ onResult(null)
351
+ return
352
+ }
353
+ copyInto(window, bitmap, handler, { if (pooled) kept.give(bitmap) else bitmap.recycle() }, onResult)
354
+ }
355
+
356
+ // Copies window into bitmap. On failure calls drop, then onResult(null).
357
+ private fun copyInto(window: Window, bitmap: Bitmap, handler: Handler, drop: () -> Unit, onResult: (Bitmap?) -> Unit) {
358
+ val listener = PixelCopy.OnPixelCopyFinishedListener { result ->
359
+ if (result == PixelCopy.SUCCESS) {
360
+ onResult(bitmap)
361
+ } else {
362
+ drop()
363
+ onResult(null)
364
+ }
365
+ }
366
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
367
+ try {
368
+ PixelCopy.request(window, bitmap, listener, handler)
369
+ } catch (_: RuntimeException) {
370
+ drop()
371
+ onResult(null)
372
+ }
373
+ return
374
+ }
375
+ // API 26-33: PixelCopy.request blocks the calling thread until the RenderThread has read the window back (40-47
376
+ // ms for 1080x2400 on a Mi 11 Lite). So the request runs on copyHandler and the main thread stays free. The
377
+ // listener and all failures still run on handler. The listener is posted to the front of handler's queue: the
378
+ // readback often finishes right as a vsync arrives, and this way the frame queued for that vsync runs after the
379
+ // listener, with the overlay, instead of before it (one frame earlier on a Mi 11 Lite). The result can also
380
+ // jump ahead of a queued cancel, prepare or onPause. Harmless: JS drops a result whose switch is no longer the
381
+ // latest, and the next removeOverlay cleans up its overlay.
382
+ val toFront = PixelCopy.OnPixelCopyFinishedListener { result ->
383
+ handler.postAtFrontOfQueue { listener.onPixelCopyFinished(result) }
384
+ }
385
+ // Off main, PixelCopy.request(window) reads peekDecorView().getViewRootImpl(), its mSurface (final field, Surface
386
+ // locks internally), mWindowAttributes.surfaceInsets and mWidth/mHeight. A stale read means either a failed copy
387
+ // (invalid surface throws, caught below, or the listener gets an error) or a size mismatch (Motion.sameSize in
388
+ // start drops the overlay), and both go down the existing failure path. postAtFrontOfQueue jumps the main queue
389
+ // with one short message per copy (two for a modal). The last one attaches the overlay and resolves.
390
+ copyHandler.post {
391
+ try {
392
+ PixelCopy.request(window, bitmap, toFront, copyHandler)
393
+ } catch (_: RuntimeException) {
394
+ handler.post {
395
+ drop()
396
+ onResult(null)
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ // Off main. Starts the copy thread so the first switch doesn't have to.
403
+ fun warmUp() {
404
+ copyHandler
405
+ }
406
+
407
+ // Main thread, after warmUp. Prepares the path the activity window's first switch takes: on API 29+, a GPU
408
+ // capture of nothing on the copy thread with no bitmap kept. Otherwise (a window that takes PixelCopy) keepOne.
409
+ fun prepareFirst(activity: Activity?, handler: Handler) {
410
+ if (warmUpOf(activity) == WarmUp.KEPT_BITMAP) {
411
+ keepOne(activity, handler)
412
+ return
413
+ }
414
+ preCreates.started()
415
+ copyHandler.post {
416
+ try {
417
+ GpuSnapshot.preCreate()
418
+ } catch (thrown: Exception) {
419
+ // Then the first switch pays for it.
420
+ Log.w(TAG, "Cannot prepare the GPU capture", thrown)
421
+ } catch (thrown: OutOfMemoryError) {
422
+ Log.w(TAG, "Cannot prepare the GPU capture", thrown)
423
+ } finally {
424
+ handler.post { preCreates.finished() }
425
+ }
426
+ }
427
+ }
428
+
429
+ // The activity window's inputs to the capture path, read the same way captureWindows reads them. Without a
430
+ // window, the GPU capture is the likely path from API 29.
431
+ private fun warmUpOf(activity: Activity?): WarmUp {
432
+ val api = Build.VERSION.SDK_INT
433
+ if (api < Build.VERSION_CODES.Q) return WarmUp.KEPT_BITMAP
434
+ val window = activity?.window
435
+ val decor = window?.peekDecorView()
436
+ if (activity == null || window == null || decor == null) return CapturePlan.warmUp(api, true, true, true, gpuOff)
437
+ return CapturePlan.warmUp(
438
+ api,
439
+ window.colorMode == ActivityInfo.COLOR_MODE_DEFAULT,
440
+ decor.isHardwareAccelerated,
441
+ GpuSnapshot.lightOf(activity, decor) != null,
442
+ gpuOff,
443
+ )
444
+ }
445
+
446
+ private val preCreates = PreCreates()
447
+
448
+ // Main thread. If kept has room, allocates a bitmap of the activity window's size on the copy thread, copies the
449
+ // window into it once and keeps it. The first copy into a bitmap is the slow one (roughly twice as long as later
450
+ // ones into the same bitmap on a Pixel 8 Pro), so the first switch gets a warm one.
451
+ fun keepOne(activity: Activity?, handler: Handler) {
452
+ val window = activity?.window ?: return
453
+ val decor = window.decorView
454
+ val width = decor.width
455
+ val height = decor.height
456
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || keeping || width <= 0 || height <= 0 ||
457
+ !kept.hasRoom(width, height)
458
+ ) {
459
+ return
460
+ }
461
+ keeping = true
462
+ copyHandler.post {
463
+ val bitmap = try {
464
+ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
465
+ } catch (_: OutOfMemoryError) {
466
+ null
467
+ }
468
+ handler.post {
469
+ if (bitmap == null) {
470
+ keeping = false
471
+ return@post
472
+ }
473
+ val keep = {
474
+ keeping = false
475
+ kept.fill(bitmap)
476
+ }
477
+ copyInto(window, bitmap, handler, { keep() }) { if (it != null) keep() }
478
+ }
479
+ }
480
+ }
481
+
482
+ // A keepOne is in flight. Main thread only.
483
+ private var keeping = false
484
+
485
+ // Never quit the copy thread. Without it PixelCopy never calls back, prepare never resolves and the switch never
486
+ // applies the theme. The GPU capture takes its images on it too.
487
+ private val copyHandler: Handler by lazy {
488
+ val thread = HandlerThread("ThemeRevealCopy", Process.THREAD_PRIORITY_DISPLAY)
489
+ thread.start()
490
+ Handler(thread.looper)
491
+ }
492
+
493
+ // Both copies run concurrently. onResult fires once, after the second one finishes.
494
+ private fun capturePair(
495
+ first: Window,
496
+ firstW: Int,
497
+ firstH: Int,
498
+ second: Window,
499
+ secondW: Int,
500
+ secondH: Int,
501
+ handler: Handler,
502
+ onResult: (Bitmap?, Bitmap?) -> Unit,
503
+ ) {
504
+ val results = arrayOfNulls<Bitmap>(2)
505
+ var pending = 2
506
+ val done = { index: Int, bitmap: Bitmap? ->
507
+ results[index] = bitmap
508
+ pending--
509
+ if (pending == 0) onResult(results[0], results[1])
510
+ }
511
+ capture(first, firstW, firstH, handler, true) { done(0, it) }
512
+ capture(second, secondW, secondH, handler, false) { done(1, it) }
513
+ }
514
+
515
+ // PixelCopy doesn't capture the dim behind a dialog: the compositor draws it.
516
+ private fun compose(back: Bitmap, front: Bitmap, frame: Frame, frontX: Int, frontY: Int, dimAlpha: Int) {
517
+ val canvas = Canvas(back)
518
+ if (dimAlpha > 0) canvas.drawColor(Color.argb(dimAlpha, 0, 0, 0))
519
+ if (frame.isEmpty) return
520
+ val source = Rect(frame.left - frontX, frame.top - frontY, frame.right - frontX, frame.bottom - frontY)
521
+ canvas.drawBitmap(front, source, Rect(frame.left, frame.top, frame.right, frame.bottom), null)
522
+ }
523
+
524
+ private fun findModalWindow(view: View, match: (Window) -> Boolean): Window? {
525
+ if (view is ReactModalHostView) {
526
+ val window = view.dialog?.takeIf { it.isShowing }?.window
527
+ if (window != null && match(window)) return window
528
+ }
529
+ if (view !is ViewGroup) return null
530
+ for (index in 0 until view.childCount) {
531
+ val found = findModalWindow(view.getChildAt(index) ?: continue, match)
532
+ if (found != null) return found
533
+ }
534
+ return null
535
+ }
536
+ }
@@ -0,0 +1,11 @@
1
+ import type { CapturePath, Transition } from './types';
2
+ declare const _default: {
3
+ prepare: (viewTag?: number) => Promise<CapturePath | null>;
4
+ start: (transition: Transition) => Promise<void>;
5
+ cancel: () => Promise<void>;
6
+ warmUp: () => Promise<void>;
7
+ capture: (viewTag?: number) => Promise<boolean>;
8
+ dropCapture: () => Promise<void>;
9
+ };
10
+ export default _default;
11
+ //# sourceMappingURL=ThemeRevealModule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThemeRevealModule.d.ts","sourceRoot":"","sources":["../src/ThemeRevealModule.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;;wBAehC,MAAM,KAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;wBACpC,UAAU,KAAG,OAAO,CAAC,IAAI,CAAC;kBAClC,OAAO,CAAC,IAAI,CAAC;kBACb,OAAO,CAAC,IAAI,CAAC;wBACL,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC;uBAC5B,OAAO,CAAC,IAAI,CAAC;;AANhC,wBAOC"}
@@ -0,0 +1,11 @@
1
+ import { requireNativeModule } from 'expo';
2
+ const native = requireNativeModule('ThemeReveal');
3
+ export default {
4
+ prepare: (viewTag) => native.prepare(viewTag),
5
+ start: (transition) => native.start(transition),
6
+ cancel: () => native.cancel(),
7
+ warmUp: () => native.warmUp(),
8
+ capture: (viewTag) => native.capture(viewTag),
9
+ dropCapture: () => native.dropCapture(),
10
+ };
11
+ //# sourceMappingURL=ThemeRevealModule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThemeRevealModule.js","sourceRoot":"","sources":["../src/ThemeRevealModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,MAAM,CAAA;AAc1C,MAAM,MAAM,GAAG,mBAAmB,CAAoB,aAAa,CAAC,CAAA;AAEpE,eAAe;IACb,OAAO,EAAE,CAAC,OAAgB,EAA+B,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;IACnF,KAAK,EAAE,CAAC,UAAsB,EAAiB,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;IAC1E,MAAM,EAAE,GAAkB,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;IAC5C,MAAM,EAAE,GAAkB,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;IAC5C,OAAO,EAAE,CAAC,OAAgB,EAAoB,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;IACxE,WAAW,EAAE,GAAkB,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE;CACvD,CAAA","sourcesContent":["import { requireNativeModule } from 'expo'\n\nimport type { CapturePath, Transition } from './types'\n\ntype NativeThemeReveal = {\n // The capture path, or null when no overlay shows.\n prepare(viewTag: number | undefined): Promise<CapturePath | null>\n start(transition: Transition): Promise<void>\n cancel(): Promise<void>\n warmUp(): Promise<void>\n capture(viewTag: number | undefined): Promise<boolean>\n dropCapture(): Promise<void>\n}\n\nconst native = requireNativeModule<NativeThemeReveal>('ThemeReveal')\n\nexport default {\n prepare: (viewTag?: number): Promise<CapturePath | null> => native.prepare(viewTag),\n start: (transition: Transition): Promise<void> => native.start(transition),\n cancel: (): Promise<void> => native.cancel(),\n warmUp: (): Promise<void> => native.warmUp(),\n capture: (viewTag?: number): Promise<boolean> => native.capture(viewTag),\n dropCapture: (): Promise<void> => native.dropCapture(),\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import type { GestureResponderEvent } from 'react-native';
2
+ import type { CapturePath, Point, Shape, TransitionType, WipeDirection } from './types';
3
+ export type SettledInfo = {
4
+ transition: TransitionType | null;
5
+ capture: CapturePath | null;
6
+ prepareMs: number;
7
+ totalMs: number;
8
+ };
9
+ export type ThemeRevealOptions<T> = {
10
+ getTheme: () => T;
11
+ setTheme: (theme: T) => void;
12
+ shape?: Shape;
13
+ durations?: Partial<Record<TransitionType, number>>;
14
+ transition?: TransitionType;
15
+ wipeDirection?: WipeDirection;
16
+ onError?: (error: unknown) => void;
17
+ onSettled?: (info: SettledInfo) => void;
18
+ warmUp?: boolean;
19
+ };
20
+ export type SwitchOptions = {
21
+ transition?: TransitionType;
22
+ shape?: Shape;
23
+ wipeDirection?: WipeDirection;
24
+ };
25
+ export type PressCapture = {
26
+ onPressIn(event: GestureResponderEvent): void;
27
+ onPressOut(event?: GestureResponderEvent): void;
28
+ };
29
+ export type ThemeReveal<T> = {
30
+ setOrigin(point: Point, viewTag?: number): void;
31
+ capture(point: Point, viewTag?: number): void;
32
+ dropCapture(): void;
33
+ pressCapture: PressCapture;
34
+ switchTo(theme: T, options?: SwitchOptions): Promise<void>;
35
+ useStart(): void;
36
+ useHeldTheme(): T | null;
37
+ };
38
+ export declare function createThemeReveal<T>(options: ThemeRevealOptions<T>): ThemeReveal<T>;
39
+ //# sourceMappingURL=createThemeReveal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createThemeReveal.d.ts","sourceRoot":"","sources":["../src/createThemeReveal.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AAMzD,OAAO,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,EAAc,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAEnG,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,cAAc,GAAG,IAAI,CAAA;IAEjC,OAAO,EAAE,WAAW,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,kBAAkB,CAAC,CAAC,IAAI;IAClC,QAAQ,EAAE,MAAM,CAAC,CAAA;IACjB,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAA;IAC5B,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,UAAU,CAAC,EAAE,cAAc,CAAA;IAC3B,aAAa,CAAC,EAAE,aAAa,CAAA;IAC7B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAA;IAGvC,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,CAAC,EAAE,cAAc,CAAA;IAC3B,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,aAAa,CAAC,EAAE,aAAa,CAAA;CAC9B,CAAA;AAID,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,CAAC,KAAK,EAAE,qBAAqB,GAAG,IAAI,CAAA;IAC7C,UAAU,CAAC,KAAK,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAA;CAChD,CAAA;AAED,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAC3B,SAAS,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/C,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7C,WAAW,IAAI,IAAI,CAAA;IACnB,YAAY,EAAE,YAAY,CAAA;IAC1B,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1D,QAAQ,IAAI,IAAI,CAAA;IAChB,YAAY,IAAI,CAAC,GAAG,IAAI,CAAA;CACzB,CAAA;AAmCD,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,OAAO,EAAE,kBAAkB,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CA8KnF"}