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