DeviceCapture.takeScreenshot() timeout issue
Description
Calling DeviceCapture.takeScreenshot() in a test rule on test failure times out and takes no screenshot
Steps to Reproduce
- Create any kind of Espresso JUnit test
- Create a test rule extending
TestWatcher()and try to take a screenshot on failure and write it to test storage.
Expected Results
Screenshot should be taken and saved in the test storage
Actual Results
No screenshot is taken, calling takeScreenshot() times out.
AndroidX Test and Android OS Versions
androidx.test.core v1.6.1 androidx.test.espresso:espresso* v3.6.1 OS: Tested on emulators with OS versions 31-33
Additional debug information
I am just a QA engineer so I can't speak about the app implementation but what I found is that forceRedrawGlobalWindowViews() finds 2 views in the context of our app. For one of the views view.isShown returns false and the redraw seems to fail on this particular one. I copied the DeviceCapture implementation and added a condition to redraw only views that are "shown" and screenshots started working.
NB The issue cannot be observed on core v1.5.0 and espresso libraries v3.5.1 but there the DeviceCapture implementation is completely different than the latest one.
Link to a public git repo demonstrating the problem:
Example test rule used
class ScreenshotTestRule : TestWatcher() {
override fun failed(e: Throwable?, description: Description?) {
super.failed(e, description)
val className = description?.testClass?.simpleName ?: "NullClassname"
val methodName = description?.methodName ?: "NullMethodName"
takeScreenshot().writeToTestStorage("${className}_${methodName}")
}}
Rule added to test class
@get:Rule(order = 1) var screenshotWatcher = ScreenshotTestRule()
seeing this issue too:
kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 5000 ms
at kotlinx.coroutines.TimeoutKt.TimeoutCancellationException(Timeout.kt:191)
at kotlinx.coroutines.TimeoutCoroutine.run(Timeout.kt:159)
at kotlinx.coroutines.EventLoopImplBase$DelayedRunnableTask.run(EventLoop.common.kt:501)
at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:280)
at kotlinx.coroutines.DefaultExecutor.run(DefaultExecutor.kt:109)
at java.lang.Thread.run(Thread.java:919)
Having the same problem here with 3.6.1 and latest Android with emulator, Studio and everything else up-to-date.
I tried to use Espresso.onIdle() and DeviceCapture.canTakeScreenshot() which returned true and to lower the version till 3.4.0.
Has anyone found a workaround yet?
@GuillaumeVT I re-implemented the DeviceCapture into my own test code. For me the issue was that it was trying to redraw a view that is not shown and it was timing out there. I added a small check to redraw only views that are shown. I don't know if this would help in your case but you can test it out
* This is a re-implementation of androidx.test.core.app.DeviceCapture from release 1.6.1 but with redrawing only views that are currently shown.
* For some reason the original times out when trying to redraw all views it finds
*/
object CustomDeviceCapture {
/**
* Returns false if calling [takeScreenshot] will fail.
*
* Taking a screenshot requires [UiAutomation] and can only be called off of the main thread. If
* this method returns false then attempting to take a screenshot will fail. Note that taking a
* screenshot may still fail if this method returns true, for example if the call to [UiAutomation]
* fails.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun canTakeScreenshot(): Boolean =
getInstrumentation().uiAutomation != null && Looper.myLooper() != Looper.getMainLooper()
/**
* Captures an image of the device's screen into a [Bitmap].
*
* This is essentially a wrapper for [UIAutomation#takeScreenshot()] that attempts to get a stable
* screenshot by forcing all the current application's root window views to redraw, and also handles
* cases where hardware renderer drawing is disabled.
*
* This API is intended for use cases like debugging where an image of the entire screen is needed.
* For use cases where the image will be used for validation, its recommended to take a more
* isolated, targeted screenshot of a specific view or compose node. See
* [androidx.test.core.view.captureToBitmap], [androidx.test.espresso.screenshot.captureToBitmap]
* and [androidx.compose.ui.test.captureToImage].
*
* This API does not support concurrent usage.
*
* This API is currently experimental and subject to change or removal.
*
* @return a [Bitmap] that contains the image
* @throws [IllegalStateException] if called on the main thread. This is a limitation of connecting
* to UiAutomation, [RuntimeException] if UiAutomation fails to take the screenshot
*/
@Suppress("FutureReturnValueIgnored")
@Throws(RuntimeException::class)
fun takeScreenshot(): Bitmap {
getInstrumentation().waitForIdleSync()
return takeScreenshotNoSync()
}
/**
* An internal variant of [takeScreenshot] that skips an idle sync call.
*
* This intended for failure handling cases where caller does not want to wait for main thread to be
* idle.
*
* @return a [Bitmap]
* @throws [IllegalStateException] if called on the main thread. This is a limitation of connecting
* to UiAutomation, [RuntimeException] if UiAutomation fails to take the screenshot
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Suppress("FutureReturnValueIgnored")
@Throws(RuntimeException::class)
fun takeScreenshotNoSync(): Bitmap {
Checks.checkState(canTakeScreenshot())
var bitmap: Bitmap?
val exception: Exception? = null
val mainHandlerDispatcher =
HandlerExecutor(Handler(Looper.getMainLooper())).asCoroutineDispatcher()
val uiAutomation = getInstrumentation().uiAutomation
if (uiAutomation == null) {
throw RuntimeException("uiautomation is null")
}
val hardwareDrawingEnabled = HardwareRendererCompat.isDrawingEnabled()
HardwareRendererCompat.setDrawingEnabled(true)
return runBlocking(mainHandlerDispatcher) {
withTimeout(5.seconds) {
forceRedrawGlobalWindowViews()
bitmap = takeScreenshotOnNextFrame(uiAutomation, hardwareDrawingEnabled)
exception?.let { throw it }
bitmap!!
}
}
}
private suspend fun forceRedrawGlobalWindowViews() {
val views = WindowInspectorCompat.getGlobalWindowViews()
Log.d("CustomDeviceCapture", "Found ${views.size} global views to redraw")
for (view in views) {
if (view.isShown) {
view.forceRedraw()
}
}
}
private suspend fun takeScreenshotOnNextFrame(
uiAutomation: UiAutomation,
hardwareDrawingEnabled: Boolean,
): Bitmap {
// wait on the next frame to increase probability the draw from previous step is
// committed
// TODO(b/289244795): use a transaction callback instead
return suspendCancellableCoroutine<Bitmap> { cont ->
Choreographer.getInstance().postFrameCallback {
// do multiple retries of uiAutomation.takeScreenshot because it is known to return null
// on API 31+ b/257274080
var bitmap: Bitmap? = null
for (i in 1..3) {
bitmap = uiAutomation.takeScreenshot()
if (bitmap != null) {
Log.i("CustomDeviceCapture", "got bitmap, returning")
break
}
}
HardwareRendererCompat.setDrawingEnabled(hardwareDrawingEnabled)
if (bitmap == null) {
Log.w("CustomDeviceCapture", "failed to get bitmap, returning exception")
cont.resumeWithException(RuntimeException("uiAutomation.takeScreenshot returned null"))
} else {
cont.resume(bitmap, {})
}
}
}
}
}```