1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 @file:SuppressLint("UseSdkSuppress")
17
18 package com.google.jetpackcamera.feature.preview.workaround
19
20 import android.annotation.SuppressLint
21 import android.app.Activity
22 import android.content.Context
23 import android.content.ContextWrapper
24 import android.graphics.Bitmap
25 import android.graphics.Rect
26 import android.os.Build
27 import android.os.Handler
28 import android.os.Looper
29 import android.view.PixelCopy
30 import android.view.View
31 import android.view.Window
32 import androidx.annotation.DoNotInline
33 import androidx.annotation.RequiresApi
34 import androidx.compose.ui.geometry.Offset
35 import androidx.compose.ui.graphics.ImageBitmap
36 import androidx.compose.ui.graphics.asImageBitmap
37 import androidx.compose.ui.platform.ViewRootForTest
38 import androidx.compose.ui.semantics.SemanticsNode
39 import androidx.compose.ui.semantics.SemanticsProperties
40 import androidx.compose.ui.test.ExperimentalTestApi
41 import androidx.compose.ui.test.SemanticsNodeInteraction
42 import androidx.compose.ui.window.DialogWindowProvider
43 import androidx.test.platform.app.InstrumentationRegistry
44 import androidx.test.platform.graphics.HardwareRendererCompat
45 import java.util.concurrent.CountDownLatch
46 import java.util.concurrent.TimeUnit
47 import kotlin.math.roundToInt
48
49 /**
50 * Workaround captureToImage method.
51 *
52 * Once composable + robolectric graphics bugs are fixed, this can be replaced with the actual
53 * [androidx.compose.ui.test.SemanticsNodeInteraction.captureToImage]. Alternative is to use
54 * instrumentations tests, but they are not run at github workflows.
55 *
56 * See [robolectric issue 8071](https://github.com/robolectric/robolectric/issues/8071) for details.
57 */
58
59 @OptIn(ExperimentalTestApi::class)
60 @RequiresApi(Build.VERSION_CODES.O)
61 fun SemanticsNodeInteraction.captureToImage(): ImageBitmap {
62 val node = fetchSemanticsNode("Failed to capture a node to bitmap.")
63 // Validate we are in popup
64 val popupParentMaybe =
65 node.findClosestParentNode(includeSelf = true) {
66 it.config.contains(SemanticsProperties.IsPopup)
67 }
68 if (popupParentMaybe != null) {
69 return processMultiWindowScreenshot(node)
70 }
71
72 val view = (node.root as ViewRootForTest).view
73
74 // If we are in dialog use its window to capture the bitmap
75 val dialogParentNodeMaybe =
76 node.findClosestParentNode(includeSelf = true) {
77 it.config.contains(SemanticsProperties.IsDialog)
78 }
79 var dialogWindow: Window? = null
80 if (dialogParentNodeMaybe != null) {
81 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
82 // TODO(b/163023027)
83 throw IllegalArgumentException("Cannot currently capture dialogs on API lower than 28!")
84 }
85
86 dialogWindow = findDialogWindowProviderInParent(view)?.window
87 ?: throw IllegalArgumentException(
88 "Could not find a dialog window provider to capture its bitmap"
89 )
90 }
91
92 val windowToUse = dialogWindow ?: view.context.getActivityWindow()
93
94 val nodeBounds = node.boundsInRoot
95 val nodeBoundsRect =
96 Rect(
97 nodeBounds.left.roundToInt(),
98 nodeBounds.top.roundToInt(),
99 nodeBounds.right.roundToInt(),
100 nodeBounds.bottom.roundToInt()
101 )
102
103 val locationInWindow = intArrayOf(0, 0)
104 view.getLocationInWindow(locationInWindow)
105 val x = locationInWindow[0]
106 val y = locationInWindow[1]
107
108 // Now these are bounds in window
109 nodeBoundsRect.offset(x, y)
110
111 return windowToUse.captureRegionToImage(nodeBoundsRect)
112 }
113
114 @RequiresApi(Build.VERSION_CODES.O)
SemanticsNodenull115 private fun SemanticsNode.findClosestParentNode(
116 includeSelf: Boolean = false,
117 selector: (SemanticsNode) -> Boolean
118 ): SemanticsNode? {
119 var currentParent = if (includeSelf) this else parent
120 while (currentParent != null) {
121 if (selector(currentParent)) {
122 return currentParent
123 } else {
124 currentParent = currentParent.parent
125 }
126 }
127
128 return null
129 }
130
131 @ExperimentalTestApi
132 @RequiresApi(Build.VERSION_CODES.O)
processMultiWindowScreenshotnull133 private fun processMultiWindowScreenshot(node: SemanticsNode): ImageBitmap {
134 val nodePositionInScreen = findNodePosition(node)
135 val nodeBoundsInRoot = node.boundsInRoot
136
137 val combinedBitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
138
139 val finalBitmap =
140 Bitmap.createBitmap(
141 combinedBitmap,
142 (nodePositionInScreen.x + nodeBoundsInRoot.left).roundToInt(),
143 (nodePositionInScreen.y + nodeBoundsInRoot.top).roundToInt(),
144 nodeBoundsInRoot.width.roundToInt(),
145 nodeBoundsInRoot.height.roundToInt()
146 )
147 return finalBitmap.asImageBitmap()
148 }
149
findNodePositionnull150 private fun findNodePosition(node: SemanticsNode): Offset {
151 val view = (node.root as ViewRootForTest).view
152 val locationOnScreen = intArrayOf(0, 0)
153 view.getLocationOnScreen(locationOnScreen)
154 val x = locationOnScreen[0]
155 val y = locationOnScreen[1]
156
157 return Offset(x.toFloat(), y.toFloat())
158 }
159
findDialogWindowProviderInParentnull160 internal fun findDialogWindowProviderInParent(view: View): DialogWindowProvider? {
161 if (view is DialogWindowProvider) {
162 return view
163 }
164 val parent = view.parent ?: return null
165 if (parent is View) {
166 return findDialogWindowProviderInParent(parent)
167 }
168 return null
169 }
170
Contextnull171 private fun Context.getActivityWindow(): Window {
172 fun Context.getActivity(): Activity {
173 return when (this) {
174 is Activity -> this
175 is ContextWrapper -> this.baseContext.getActivity()
176 else -> throw IllegalStateException(
177 "Context is not an Activity context, but a ${javaClass.simpleName} context. " +
178 "An Activity context is required to get a Window instance"
179 )
180 }
181 }
182 return getActivity().window
183 }
184
185 @RequiresApi(Build.VERSION_CODES.O)
Windownull186 private fun Window.captureRegionToImage(boundsInWindow: Rect): ImageBitmap {
187 // Turn on hardware rendering, if necessary
188 return withDrawingEnabled {
189 // Then we generate the bitmap
190 generateBitmap(boundsInWindow).asImageBitmap()
191 }
192 }
193
withDrawingEnablednull194 private fun <R> withDrawingEnabled(block: () -> R): R {
195 val wasDrawingEnabled = HardwareRendererCompat.isDrawingEnabled()
196 try {
197 if (!wasDrawingEnabled) {
198 HardwareRendererCompat.setDrawingEnabled(true)
199 }
200 return block.invoke()
201 } finally {
202 if (!wasDrawingEnabled) {
203 HardwareRendererCompat.setDrawingEnabled(false)
204 }
205 }
206 }
207
208 @RequiresApi(Build.VERSION_CODES.O)
Windownull209 private fun Window.generateBitmap(boundsInWindow: Rect): Bitmap {
210 val destBitmap =
211 Bitmap.createBitmap(
212 boundsInWindow.width(),
213 boundsInWindow.height(),
214 Bitmap.Config.ARGB_8888
215 )
216 generateBitmapFromPixelCopy(boundsInWindow, destBitmap)
217 return destBitmap
218 }
219
220 @RequiresApi(Build.VERSION_CODES.O)
221 private object PixelCopyHelper {
222 @DoNotInline
requestnull223 fun request(
224 source: Window,
225 srcRect: Rect?,
226 dest: Bitmap,
227 listener: PixelCopy.OnPixelCopyFinishedListener,
228 listenerThread: Handler
229 ) {
230 PixelCopy.request(source, srcRect, dest, listener, listenerThread)
231 }
232 }
233
234 @RequiresApi(Build.VERSION_CODES.O)
Windownull235 private fun Window.generateBitmapFromPixelCopy(boundsInWindow: Rect, destBitmap: Bitmap) {
236 val latch = CountDownLatch(1)
237 var copyResult = 0
238 val onCopyFinished =
239 PixelCopy.OnPixelCopyFinishedListener { result ->
240 copyResult = result
241 latch.countDown()
242 }
243 PixelCopyHelper.request(
244 this,
245 boundsInWindow,
246 destBitmap,
247 onCopyFinished,
248 Handler(Looper.getMainLooper())
249 )
250
251 if (!latch.await(1, TimeUnit.SECONDS)) {
252 throw AssertionError("Failed waiting for PixelCopy!")
253 }
254 if (copyResult != PixelCopy.SUCCESS) {
255 throw AssertionError("PixelCopy failed!")
256 }
257 }
258