Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DeviceCapture.takeScreenshot() timeout issue #2273

Open
nikolas-scaleforce opened this issue Aug 21, 2024 · 3 comments
Open

DeviceCapture.takeScreenshot() timeout issue #2273

nikolas-scaleforce opened this issue Aug 21, 2024 · 3 comments

Comments

@nikolas-scaleforce
Copy link

nikolas-scaleforce commented Aug 21, 2024

Description

Calling DeviceCapture.takeScreenshot() in a test rule on test failure times out and takes no screenshot

Steps to Reproduce

  1. Create any kind of Espresso JUnit test
  2. 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()

@ZakTaccardi
Copy link

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)

@GuillaumeVT
Copy link

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?

@nikolas-scaleforce
Copy link
Author

nikolas-scaleforce commented Feb 3, 2025

@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, {})
                }
            }
        }
    }

}```

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants