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,32 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.themereveal'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ buildscript {
13
+ ext.safeExtGet = { prop, fallback ->
14
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
15
+ }
16
+ }
17
+
18
+ android {
19
+ namespace "expo.modules.themereveal"
20
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
21
+ defaultConfig {
22
+ minSdkVersion safeExtGet("minSdkVersion", 24)
23
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
24
+ versionCode 1
25
+ versionName "0.1.0"
26
+ }
27
+ }
28
+
29
+ dependencies {
30
+ implementation 'com.facebook.react:react-android'
31
+ testImplementation 'junit:junit:4.13.2'
32
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,4 @@
1
+ # Baseline Profile for react-native-theme-reveal: every class and method of the module, so a Play install compiles the
2
+ # switch path ahead of time.
3
+ HSPLexpo/modules/themereveal/**->**(**)**
4
+ Lexpo/modules/themereveal/**;
@@ -0,0 +1,199 @@
1
+ package expo.modules.themereveal
2
+
3
+ import android.graphics.HardwareRenderer
4
+ import android.os.Build
5
+ import kotlin.math.min
6
+
7
+ // js: name JS sees for it (src/types.ts CapturePath).
8
+ internal enum class CapturePath(val js: String) {
9
+ GPU("gpu"),
10
+ PIXEL_COPY("pixelcopy"),
11
+ }
12
+
13
+ // Why a GPU capture gave no bitmap.
14
+ internal enum class GpuFailure {
15
+ // Renderer lost its surface, was stopped or dropped the frame. Next switch tries the GPU again.
16
+ SYNC,
17
+
18
+ // No image arrived within GPU_TIMEOUT_MS.
19
+ TIMEOUT,
20
+
21
+ // Capture threw.
22
+ ERROR,
23
+ }
24
+
25
+ // What a warm-up prepares for the first switch.
26
+ internal enum class WarmUp {
27
+ // GPU capture's classes, reader and renderer. No bitmap kept, the GPU capture makes its own.
28
+ GPU,
29
+
30
+ // One window-size bitmap for PixelCopy, kept with one copy into it.
31
+ KEPT_BITMAP,
32
+ }
33
+
34
+ internal class LightSource(val x: Float, val y: Float, val z: Float, val radius: Float)
35
+
36
+ internal object CapturePlan {
37
+ // First capture in a fresh process took 50 ms on a Pixel 8 Pro, a warm one 12-32 ms on both lab phones.
38
+ const val GPU_TIMEOUT_MS = 250L
39
+
40
+ private const val SYNC_FAILED = HardwareRenderer.SYNC_LOST_SURFACE_REWARD_IF_FOUND or
41
+ HardwareRenderer.SYNC_CONTEXT_IS_STOPPED or HardwareRenderer.SYNC_FRAME_DROPPED
42
+
43
+ // GPU capture renders in sRGB, so a wide color window stays on PixelCopy. So does a window without hardware
44
+ // acceleration, since its software canvas can't draw the hardware bitmap. Same for a view change waiting for its
45
+ // frame while force dark can apply: the capture would sync it first, without the force dark transform.
46
+ fun path(
47
+ apiLevel: Int,
48
+ defaultColorMode: Boolean,
49
+ hardwareAccelerated: Boolean,
50
+ pendingUnderForceDark: Boolean,
51
+ gpuOff: Boolean,
52
+ ): CapturePath =
53
+ if (apiLevel >= Build.VERSION_CODES.Q && defaultColorMode && hardwareAccelerated && !pendingUnderForceDark &&
54
+ !gpuOff
55
+ ) {
56
+ CapturePath.GPU
57
+ } else {
58
+ CapturePath.PIXEL_COPY
59
+ }
60
+
61
+ // ViewRootImpl turns on force dark for a light theme that allows it, in night mode. From API 36 the color
62
+ // inversion setting can darken a light theme too, and apps can't read that setting.
63
+ fun forceDarkCanApply(apiLevel: Int, nightMode: Boolean, lightTheme: Boolean, forceDarkAllowed: Boolean): Boolean =
64
+ apiLevel >= Build.VERSION_CODES.Q && nightMode && lightTheme &&
65
+ (forceDarkAllowed || apiLevel >= Build.VERSION_CODES.BAKLAVA)
66
+
67
+ // Warm-up prepares the path the activity window's first switch takes: GPU capture on API 29+, otherwise
68
+ // PixelCopy. hasLight: the theme has the light values for shadows (GpuSnapshot.lightOf).
69
+ fun warmUp(
70
+ apiLevel: Int,
71
+ defaultColorMode: Boolean,
72
+ hardwareAccelerated: Boolean,
73
+ hasLight: Boolean,
74
+ gpuOff: Boolean,
75
+ ): WarmUp =
76
+ if (hasLight && path(apiLevel, defaultColorMode, hardwareAccelerated, false, gpuOff) == CapturePath.GPU) {
77
+ WarmUp.GPU
78
+ } else {
79
+ WarmUp.KEPT_BITMAP
80
+ }
81
+
82
+ fun syncFailed(result: Int): Boolean = result and SYNC_FAILED != 0
83
+
84
+ // A timeout or error can repeat on every switch, so the process stops trying the GPU. A timeout while the warm-up's
85
+ // pre-create holds the copy thread (copyThreadBusy) says nothing about the GPU, so the next switch tries it.
86
+ fun turnsGpuOff(failure: GpuFailure, copyThreadBusy: Boolean): Boolean = when (failure) {
87
+ GpuFailure.SYNC -> false
88
+ GpuFailure.TIMEOUT -> !copyThreadBusy
89
+ GpuFailure.ERROR -> true
90
+ }
91
+
92
+ // Where ThreadedRenderer.setLightCenter puts a window's light: above the display's horizontal center, lightY from
93
+ // its top, shifted into window space. From API 34 the height scales with the display's smallest side.
94
+ fun light(
95
+ apiLevel: Int,
96
+ displayWidth: Int,
97
+ displayHeight: Int,
98
+ density: Float,
99
+ windowLeft: Int,
100
+ windowTop: Int,
101
+ lightY: Float,
102
+ lightZ: Float,
103
+ lightRadius: Float,
104
+ ): LightSource {
105
+ val z = if (apiLevel >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
106
+ val ratio = min(displayWidth, displayHeight) / (450f * density)
107
+ lightZ * (ratio + 2) / 3f
108
+ } else {
109
+ lightZ
110
+ }
111
+ return LightSource(displayWidth / 2f - windowLeft, lightY - windowTop, z, lightRadius)
112
+ }
113
+ }
114
+
115
+ // GPU jobs on the copy thread: the warm-up's pre-creates, plus the RenderThread reveal's prepared targets and
116
+ // pre-imports. All of them count as pre-creates here. Releases of captures and targets (GpuSnapshot.free) run on that
117
+ // thread too and aren't counted. A GPU capture's image arrives on that thread, so it can wait behind a pre-create
118
+ // past the timeout. The first such timeout of each pre-create is forgiven and the GPU path stays on. If that
119
+ // pre-create still holds the copy thread at a later switch, that switch skips the GPU capture. So a pre-create that
120
+ // never returns costs only one switch the timeout. Main thread only.
121
+ internal class PreCreates {
122
+ // How many have started.
123
+ var count = 0
124
+ private set
125
+ private var running = 0
126
+ private var forgivenAt = -1
127
+
128
+ // One is queued or running.
129
+ val busy: Boolean
130
+ get() = running > 0
131
+
132
+ fun started() {
133
+ count++
134
+ running++
135
+ }
136
+
137
+ fun finished() {
138
+ running--
139
+ }
140
+
141
+ // At a switch's start, the copy thread is still running a pre-create that already cost a switch the timeout.
142
+ fun skipsGpu(): Boolean = running > 0 && forgivenAt == count
143
+
144
+ // A failed GPU capture. busyAtStart and countAtStart are read when the capture started.
145
+ fun turnsGpuOff(failure: GpuFailure, busyAtStart: Boolean, countAtStart: Int): Boolean {
146
+ val copyThreadBusy = busyAtStart || running > 0 || count != countAtStart
147
+ if (failure == GpuFailure.TIMEOUT && copyThreadBusy) forgivenAt = count
148
+ return CapturePlan.turnsGpuOff(failure, copyThreadBusy)
149
+ }
150
+ }
151
+
152
+ // How a GPU capture ends: image, timeout or failure, whichever comes first. The rest get dropped and a late bitmap
153
+ // is recycled. onResult runs once. release runs once, after onResult for an image and before it for a failure.
154
+ // afterImage runs that release after an image: right away by default, or later (after the overlay's frame). Main
155
+ // thread only.
156
+ internal class CaptureEnd<B : Any>(
157
+ private val release: () -> Unit,
158
+ private val recycle: (B) -> Unit,
159
+ private val cancelTimeout: () -> Unit,
160
+ private val onResult: (B?, GpuFailure?) -> Unit,
161
+ private val afterImage: (() -> Unit) -> Unit = { it() },
162
+ ) {
163
+ private var finished = false
164
+
165
+ fun image(bitmap: B?) {
166
+ if (finished) {
167
+ bitmap?.let(recycle)
168
+ return
169
+ }
170
+ finished = true
171
+ cancelTimeout()
172
+ if (bitmap == null) {
173
+ release()
174
+ onResult(null, GpuFailure.ERROR)
175
+ return
176
+ }
177
+ // Overlay goes up first, then the renderer goes away, once afterImage runs it.
178
+ try {
179
+ onResult(bitmap, null)
180
+ } finally {
181
+ afterImage(release)
182
+ }
183
+ }
184
+
185
+ fun timeout() {
186
+ if (finished) return
187
+ finished = true
188
+ release()
189
+ onResult(null, GpuFailure.TIMEOUT)
190
+ }
191
+
192
+ fun fail(failure: GpuFailure) {
193
+ if (finished) return
194
+ finished = true
195
+ cancelTimeout()
196
+ release()
197
+ onResult(null, failure)
198
+ }
199
+ }
@@ -0,0 +1,50 @@
1
+ package expo.modules.themereveal
2
+
3
+ internal object CaptureRules {
4
+ // A press-in capture no switch uses is freed after this long, so a long press doesn't hold on to a full-window
5
+ // bitmap.
6
+ const val TIMEOUT_MS = 3_000L
7
+
8
+ // A capture's PixelCopy result is kept only if nothing voided the capture (ticket), no prepare started during the
9
+ // copy (generation) and no overlay is showing. Otherwise it could hold a frame from before another switch.
10
+ fun keepResult(
11
+ ticket: Int,
12
+ currentTicket: Int,
13
+ generation: Int,
14
+ currentGeneration: Int,
15
+ overlayShown: Boolean,
16
+ ): Boolean = ticket == currentTicket && generation == currentGeneration && !overlayShown
17
+
18
+ // A held capture can stand in for prepare's own capture if nothing voided it (ticket, a guard: voiding also frees
19
+ // it), no other prepare ran since it started (generation is the one before this prepare's), it shows the window
20
+ // prepare would capture, that window still has the snapshot's size, and it's no older than TIMEOUT_MS.
21
+ fun usable(
22
+ ticket: Int,
23
+ currentTicket: Int,
24
+ generation: Int,
25
+ generationBefore: Int,
26
+ sameWindow: Boolean,
27
+ snapshotW: Int,
28
+ snapshotH: Int,
29
+ coverW: Int,
30
+ coverH: Int,
31
+ takenAtMs: Long,
32
+ nowMs: Long,
33
+ ): Boolean {
34
+ val age = nowMs - takenAtMs
35
+ return ticket == currentTicket && generation == generationBefore && sameWindow &&
36
+ Motion.sameSize(snapshotW, snapshotH, coverW, coverH) && age >= 0 && age < TIMEOUT_MS
37
+ }
38
+
39
+ // The capture target for the next switch (KeptTarget) is only prepared while no switch or press-in is using the
40
+ // copy thread or the RenderThread: no overlay showing, no press-in capture running or held, no switch capture
41
+ // running. The prepare blocks the copy thread, which is where those captures get their images. Foreground only,
42
+ // too.
43
+ fun keepsTargetNow(
44
+ overlayShown: Boolean,
45
+ captureRunning: Boolean,
46
+ captureHeld: Boolean,
47
+ switchCapturing: Boolean,
48
+ resumed: Boolean,
49
+ ): Boolean = !overlayShown && !captureRunning && !captureHeld && !switchCapturing && resumed
50
+ }
@@ -0,0 +1,367 @@
1
+ package expo.modules.themereveal
2
+
3
+ import android.app.Activity
4
+ import android.content.res.Configuration
5
+ import android.graphics.Bitmap
6
+ import android.graphics.Canvas
7
+ import android.graphics.ColorSpace
8
+ import android.graphics.HardwareRenderer
9
+ import android.graphics.PixelFormat
10
+ import android.graphics.Rect
11
+ import android.graphics.RenderNode
12
+ import android.hardware.HardwareBuffer
13
+ import android.media.ImageReader
14
+ import android.os.Build
15
+ import android.os.Handler
16
+ import android.util.DisplayMetrics
17
+ import android.util.TypedValue
18
+ import android.util.Log
19
+ import android.view.Choreographer
20
+ import android.view.View
21
+ import androidx.annotation.RequiresApi
22
+
23
+ // Elevation shadows: where the light is and how dark its shadows are.
24
+ internal class Lighting(val source: LightSource, val ambientAlpha: Float, val spotAlpha: Float)
25
+
26
+ // GPU capture target made ready ahead of the capture (GpuSnapshot.prepareOutput): its reader, its renderer with the
27
+ // surface already made, and its root node. release frees it, once, unless a capture took it. Called on main, the work
28
+ // runs on the copy thread (GpuSnapshot.free). what names it in the debug log.
29
+ @RequiresApi(Build.VERSION_CODES.Q)
30
+ internal class GpuOutput(
31
+ val width: Int,
32
+ val height: Int,
33
+ val reader: ImageReader,
34
+ val renderer: HardwareRenderer,
35
+ val node: RenderNode,
36
+ private val readerHandler: Handler,
37
+ ) {
38
+ fun release(what: String = "an unused prepared target") =
39
+ GpuSnapshot.free(readerHandler, renderer, reader, node, what)
40
+ }
41
+
42
+ // API 29+: window snapshot drawn on the GPU into a hardware bitmap. No readback to the CPU and no upload back to the
43
+ // GPU like PixelCopy needs.
44
+ @RequiresApi(Build.VERSION_CODES.Q)
45
+ internal object GpuSnapshot {
46
+ private const val USAGE = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
47
+
48
+ private const val TAG = "ThemeReveal"
49
+
50
+ // Debug log name for releasing a capture's own renderer, as opposed to an unused or kept target.
51
+ const val CAPTURE_RENDERER = "a capture's renderer"
52
+
53
+ // Longest a capture's release waits for the frame after its image. No frame comes while the screen is off.
54
+ private const val RELEASE_TIMEOUT_MS = 100L
55
+
56
+ // Light the window's renderer uses for elevation shadows (ThreadedRenderer), or null if the theme doesn't define
57
+ // it.
58
+ fun lightOf(activity: Activity, decor: View): Lighting? {
59
+ val display = decor.display ?: return null
60
+ val theme = activity.theme
61
+ val value = TypedValue()
62
+ fun dimension(name: String): Float? {
63
+ // lightY, lightZ and lightRadius are private framework attributes.
64
+ @Suppress("DiscouragedApi")
65
+ val id = activity.resources.getIdentifier(name, "attr", "android")
66
+ if (id == 0 || !theme.resolveAttribute(id, value, true)) return null
67
+ return value.getDimension(activity.resources.displayMetrics)
68
+ }
69
+ val lightY = dimension("lightY") ?: return null
70
+ val lightZ = dimension("lightZ") ?: return null
71
+ val radius = dimension("lightRadius") ?: return null
72
+ if (!theme.resolveAttribute(android.R.attr.ambientShadowAlpha, value, true)) return null
73
+ val ambient = value.float
74
+ if (!theme.resolveAttribute(android.R.attr.spotShadowAlpha, value, true)) return null
75
+ val spot = value.float
76
+ val metrics = DisplayMetrics()
77
+ @Suppress("DEPRECATION")
78
+ display.getRealMetrics(metrics)
79
+ val inWindow = IntArray(2).also { decor.getLocationInWindow(it) }
80
+ val onScreen = WindowSnapshot.locationOf(decor)
81
+ val source = CapturePlan.light(
82
+ Build.VERSION.SDK_INT,
83
+ metrics.widthPixels,
84
+ metrics.heightPixels,
85
+ metrics.density,
86
+ onScreen[0] - inWindow[0],
87
+ onScreen[1] - inWindow[1],
88
+ lightY,
89
+ lightZ,
90
+ radius,
91
+ )
92
+ return Lighting(source, ambient, spot)
93
+ }
94
+
95
+ // Draws a window's views the way its renderer does: in place in the window, with Z order and shadows. Child views
96
+ // draw the display lists they already have. UI thread only.
97
+ fun drawWindow(canvas: Canvas, decor: View) {
98
+ val at = IntArray(2).also { decor.getLocationInWindow(it) }
99
+ val count = canvas.save()
100
+ canvas.translate(at[0].toFloat(), at[1].toFloat())
101
+ canvas.enableZ()
102
+ decor.draw(canvas)
103
+ canvas.disableZ()
104
+ canvas.restoreToCount(count)
105
+ }
106
+
107
+ // Warm-up, off the main thread: a 1x1 capture with no window in it. Loads the reader's and renderer's classes and
108
+ // native code and draws one frame, so the first switch doesn't have to. Draws no view, so it can run off main.
109
+ fun preCreate() {
110
+ val node = RenderNode("ThemeRevealWarmUp")
111
+ val images = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 1, USAGE)
112
+ var renderer: HardwareRenderer? = null
113
+ try {
114
+ node.setPosition(0, 0, 1, 1)
115
+ node.beginRecording(1, 1)
116
+ node.endRecording()
117
+ val output = HardwareRenderer()
118
+ renderer = output
119
+ output.setSurface(images.surface)
120
+ output.setContentRoot(node)
121
+ output.setOpaque(false)
122
+ output.setLightSourceGeometry(0f, 0f, 1f, 1f)
123
+ output.setLightSourceAlpha(0f, 0f)
124
+ output.createRenderRequest().syncAndDraw()
125
+ // Waits until the frame is drawn.
126
+ output.stop()
127
+ images.acquireLatestImage()?.use { image ->
128
+ image.hardwareBuffer?.use { Bitmap.wrapHardwareBuffer(it, ColorSpace.get(ColorSpace.Named.SRGB))?.recycle() }
129
+ }
130
+ } finally {
131
+ renderer?.destroy()
132
+ images.close()
133
+ node.discardDisplayList()
134
+ }
135
+ }
136
+
137
+ // Target side of pre-importing a held snapshot.
138
+ private const val PRE_IMPORT_SIZE = 8
139
+
140
+ // Records the snapshot, drawn scaled down, into a tiny node. Runs on the UI thread so freeing the bitmap can't
141
+ // race the recording. The display list keeps the bitmap's buffer alive until preImport discards it. Draws no
142
+ // view.
143
+ fun preImportNode(bitmap: Bitmap): RenderNode {
144
+ val node = RenderNode("ThemeRevealPreImport")
145
+ try {
146
+ node.setPosition(0, 0, PRE_IMPORT_SIZE, PRE_IMPORT_SIZE)
147
+ val canvas = node.beginRecording(PRE_IMPORT_SIZE, PRE_IMPORT_SIZE)
148
+ try {
149
+ canvas.drawBitmap(bitmap, null, Rect(0, 0, PRE_IMPORT_SIZE, PRE_IMPORT_SIZE), null)
150
+ } finally {
151
+ node.endRecording()
152
+ }
153
+ } catch (thrown: RuntimeException) {
154
+ node.discardDisplayList()
155
+ throw thrown
156
+ }
157
+ return node
158
+ }
159
+
160
+ // Draws the node into a tiny offscreen target and waits for the frame, then frees target and node. The shared
161
+ // RenderThread imports the snapshot's buffer as a texture now, during the hold, instead of in the overlay's first
162
+ // frame. Draws no view, so it runs off main.
163
+ fun preImport(node: RenderNode) {
164
+ var images: ImageReader? = null
165
+ var renderer: HardwareRenderer? = null
166
+ try {
167
+ val reader = ImageReader.newInstance(PRE_IMPORT_SIZE, PRE_IMPORT_SIZE, PixelFormat.RGBA_8888, 1, USAGE)
168
+ images = reader
169
+ val output = HardwareRenderer()
170
+ renderer = output
171
+ output.setSurface(reader.surface)
172
+ output.setContentRoot(node)
173
+ output.setOpaque(false)
174
+ output.setLightSourceGeometry(0f, 0f, 1f, 1f)
175
+ output.setLightSourceAlpha(0f, 0f)
176
+ output.createRenderRequest().syncAndDraw()
177
+ // Waits until the frame is drawn.
178
+ output.stop()
179
+ reader.acquireLatestImage()?.close()
180
+ } finally {
181
+ renderer?.destroy()
182
+ images?.close()
183
+ node.discardDisplayList()
184
+ }
185
+ }
186
+
187
+ // Gets a width x height capture target ready for a later capture: draws one empty frame into it and waits for that
188
+ // frame, so the surface exists on the RenderThread and its buffer is allocated. The capture's frame reuses that
189
+ // buffer. Draws no view, so it runs off main, on readerHandler's thread. The renderer stays started, since stop()
190
+ // would let its surface go.
191
+ fun prepareOutput(width: Int, height: Int, readerHandler: Handler): GpuOutput {
192
+ val node = RenderNode("ThemeRevealSnapshot")
193
+ var images: ImageReader? = null
194
+ var renderer: HardwareRenderer? = null
195
+ try {
196
+ val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1, USAGE)
197
+ images = reader
198
+ node.setPosition(0, 0, width, height)
199
+ node.beginRecording(width, height)
200
+ node.endRecording()
201
+ val output = HardwareRenderer()
202
+ renderer = output
203
+ output.setSurface(reader.surface)
204
+ output.setContentRoot(node)
205
+ output.setOpaque(false)
206
+ output.setLightSourceGeometry(0f, 0f, 1f, 1f)
207
+ output.setLightSourceAlpha(0f, 0f)
208
+ // Waits until the frame is presented, so its image is already in the reader.
209
+ output.createRenderRequest().setWaitForPresent(true).syncAndDraw()
210
+ // Back to the reader, so the capture's frame dequeues this buffer again.
211
+ reader.acquireNextImage()?.close()
212
+ return GpuOutput(width, height, reader, output, node, readerHandler)
213
+ } catch (thrown: Throwable) {
214
+ renderer?.destroy()
215
+ images?.close()
216
+ node.discardDisplayList()
217
+ throw thrown
218
+ }
219
+ }
220
+
221
+ // Runs release on handler after the next frame, which is the overlay's frame when an image just attached the
222
+ // overlay. release only posts the work to the copy thread (free), so its RenderThread work lands after that frame.
223
+ // A timeout runs it if no frame comes.
224
+ private fun afterNextFrame(handler: Handler, release: () -> Unit) {
225
+ val once = Once(release)
226
+ lateinit var timeout: Runnable
227
+ val frame = Choreographer.FrameCallback {
228
+ handler.removeCallbacks(timeout)
229
+ // A message posted after this one runs after the frame's traversal.
230
+ handler.post { once.run() }
231
+ }
232
+ timeout = Runnable {
233
+ Choreographer.getInstance().removeFrameCallback(frame)
234
+ once.run()
235
+ }
236
+ Choreographer.getInstance().postFrameCallback(frame)
237
+ handler.postDelayed(timeout, RELEASE_TIMEOUT_MS)
238
+ }
239
+
240
+ // Frees a capture's renderer, reader and node on readerHandler's thread (the reader's own) instead of main:
241
+ // renderer.destroy waits for the RenderThread, 1.0-19.4 ms on a Mi 11 Lite. By then nothing else uses them. Main is
242
+ // done with the renderer, and its stop() and the image listener already ran on that same thread, so the close can't
243
+ // free an image the listener is still reading. A bitmap from the image holds its own reference to the buffer.
244
+ fun free(
245
+ readerHandler: Handler,
246
+ renderer: HardwareRenderer?,
247
+ reader: ImageReader?,
248
+ node: RenderNode,
249
+ what: String = CAPTURE_RENDERER,
250
+ ) {
251
+ readerHandler.post {
252
+ val start = System.nanoTime()
253
+ try {
254
+ renderer?.destroy()
255
+ } finally {
256
+ reader?.close()
257
+ node.discardDisplayList()
258
+ }
259
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
260
+ val ms = (System.nanoTime() - start) / 1e6
261
+ Log.d(TAG, "released $what in %.1f ms on the copy thread".format(ms))
262
+ }
263
+ }
264
+ }
265
+
266
+ // Force dark can darken this activity's windows (see CapturePlan.forceDarkCanApply).
267
+ fun forceDarkCanApply(activity: Activity): Boolean {
268
+ val value = TypedValue()
269
+ val theme = activity.theme
270
+ val lightTheme = !theme.resolveAttribute(android.R.attr.isLightTheme, value, true) || value.data != 0
271
+ val allowed = theme.resolveAttribute(android.R.attr.forceDarkAllowed, value, true) && value.data != 0
272
+ val night = activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
273
+ Configuration.UI_MODE_NIGHT_YES
274
+ return CapturePlan.forceDarkCanApply(Build.VERSION.SDK_INT, night, lightTheme, allowed)
275
+ }
276
+
277
+ // Records draw on the UI thread, renders it into a hardware buffer and calls onResult once on handler, with the
278
+ // bitmap or with null and the reason. readerHandler gets the image, since taking it waits for the GPU to finish.
279
+ // redraw lists the decor views of windows that had a view change waiting for its frame. prepared: a target from
280
+ // prepareOutput, made with the same readerHandler, to draw into instead of a new one. The capture takes it and frees
281
+ // it when done. One of another size is freed right away.
282
+ fun capture(
283
+ width: Int,
284
+ height: Int,
285
+ light: Lighting,
286
+ redraw: List<View>,
287
+ handler: Handler,
288
+ readerHandler: Handler,
289
+ draw: (Canvas) -> Unit,
290
+ prepared: GpuOutput?,
291
+ onResult: (Bitmap?, GpuFailure?) -> Unit,
292
+ ) {
293
+ val ready = prepared?.takeIf { it.width == width && it.height == height }
294
+ if (prepared != null && ready == null) prepared.release()
295
+ val node = ready?.node ?: RenderNode("ThemeRevealSnapshot")
296
+ var reader: ImageReader? = null
297
+ var renderer: HardwareRenderer? = null
298
+ lateinit var timeout: Runnable
299
+ val end = CaptureEnd<Bitmap>(
300
+ release = {
301
+ if (ready != null) ready.release(CAPTURE_RENDERER) else free(readerHandler, renderer, reader, node)
302
+ },
303
+ recycle = { it.recycle() },
304
+ cancelTimeout = { handler.removeCallbacks(timeout) },
305
+ onResult = onResult,
306
+ afterImage = { release -> afterNextFrame(handler, release) },
307
+ )
308
+ timeout = Runnable { end.timeout() }
309
+ try {
310
+ node.setPosition(0, 0, width, height)
311
+ val canvas = node.beginRecording(width, height)
312
+ try {
313
+ draw(canvas)
314
+ } finally {
315
+ node.endRecording()
316
+ }
317
+ val images = ready?.reader ?: ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 1, USAGE)
318
+ reader = images
319
+ images.setOnImageAvailableListener({ source ->
320
+ val bitmap = try {
321
+ source.acquireLatestImage()?.use { image ->
322
+ image.hardwareBuffer?.use { Bitmap.wrapHardwareBuffer(it, ColorSpace.get(ColorSpace.Named.SRGB)) }
323
+ }
324
+ } catch (_: RuntimeException) {
325
+ null
326
+ }
327
+ // Front of the queue, like the PixelCopy listener, so the overlay can still make a frame that's due.
328
+ handler.postAtFrontOfQueue { end.image(bitmap) }
329
+ }, readerHandler)
330
+ val output = ready?.renderer ?: HardwareRenderer()
331
+ renderer = output
332
+ if (ready == null) {
333
+ output.setSurface(images.surface)
334
+ output.setContentRoot(node)
335
+ output.setOpaque(false)
336
+ }
337
+ output.setLightSourceGeometry(light.source.x, light.source.y, light.source.z, light.source.radius)
338
+ output.setLightSourceAlpha(light.ambientAlpha, light.spotAlpha)
339
+ val result = output.createRenderRequest().syncAndDraw()
340
+ // The sync consumed the damage of views that changed since the window's last frame, so the window would skip
341
+ // redrawing them. Redraw these windows in full.
342
+ redraw.forEach { it.invalidate() }
343
+ if (CapturePlan.syncFailed(result)) {
344
+ end.fail(GpuFailure.SYNC)
345
+ return
346
+ }
347
+ // A RenderThread animation in the window (a ripple, say) would make this renderer redraw the whole window
348
+ // every vsync until release. Stop it once its frame is drawn. stop() waits for that on the RenderThread, so
349
+ // it runs off the main thread. The release (free) runs later on the same thread.
350
+ readerHandler.post {
351
+ try {
352
+ output.stop()
353
+ } catch (_: RuntimeException) {
354
+ // Nothing to stop.
355
+ }
356
+ }
357
+ } catch (_: RuntimeException) {
358
+ end.fail(GpuFailure.ERROR)
359
+ return
360
+ } catch (_: OutOfMemoryError) {
361
+ // HardwareRenderer throws it when HWUI can't make its native proxy.
362
+ end.fail(GpuFailure.ERROR)
363
+ return
364
+ }
365
+ handler.postDelayed(timeout, CapturePlan.GPU_TIMEOUT_MS)
366
+ }
367
+ }