android / android-test

An extensive framework for testing Android apps
https://android.github.io/android-test
Apache License 2.0
1.16k stars 314 forks source link

Dialog click not working on Pixel 3 (Android 10) #444

Open lsuski opened 5 years ago

lsuski commented 5 years ago

Description

On Pixel 3 (this is probably android 10 specific issue but I've tested it only on Pixel 3) simple clicking on dialog button immediately after it is shown does not work

Steps to Reproduce

  1. Animations are enabled on device.

  2. Run ExampleInstrumentedTest

Expected Results

ExampleInstrumentedTest should pass

Actual Results

ExampleInstrumentedTest fails on Pixel 3 and passes on older devices

AndroidX Test and Android OS Versions

espresso-core:3.2.0 androidx.test.ext:junit:1.1.1 androidx.appcompat:appcompat:1.0.2

Android 10 - api 29

Link to a public git repo demonstrating the problem:

https://github.com/lsuski/espresso-android10-bug-sample

asavill commented 5 years ago

We are seeing this as well.

The first click on the dialog just doesn't seem to register.

If you put a Thread.Sleep() (hack) just after the dialog is displayed (right before the click), it will work. If you change the click to a doubleClick(), it will also work.

If we run our tests individually, they pass. But if we run the entire class with multiple tests, that is when they become flaky.

I have created a sample project here https://github.com/asavill/esspresso_alert_dialog_issue with an espresso test class which fails 100% of the time when running on Android 10 and passes 100% of the time when running < Android 10.

Thank you to @lsuski for providing the other sample application.

Steps to Reproduce

Run ExampleInstrumentedTest

Expected Results

ExampleInstrumentedTest should pass

Actual Results

ExampleInstrumentedTest fails on API 29 and passes on older API versions.

AndroidX Test and Android OS Versions (have also tested on latest alpha versions as of 16/11/19)

espresso-core:3.1.1 androidx.test.ext:junit:1.1.0 androidx.appcompat:appcompat:1.1.1

Android 10 - api 29

brettchabot commented 4 years ago

Can you reproduce the issue with animations disabled? Its strongly recommended to disable animations when using espresso. https://developer.android.com/training/testing/espresso/setup

asavill commented 4 years ago

Can you reproduce the issue with animations disabled? Its strongly recommended to disable animations when using espresso. https://developer.android.com/training/testing/espresso/setup

Hello, yes animations on or off does not make a difference.

brettchabot commented 4 years ago

Thanks we'll take a look

ShivamPokhriyal commented 4 years ago

Loved the way you closed the issue without waiting for a response. And then haven't updated anything for like 4 months now?

lsuski commented 4 years ago

This is android 10 issue. It happens in couple of devices with 10 we have for testing. Even after updating Nokia7.2 to android 10 this bug starts to show up.

rh-id commented 4 years ago

Will this be fixed ? I'm also encountering this issue.

diegoperini commented 4 years ago

I confirm this happens on Samsung S10.

tauntz commented 3 years ago

For what it's worth, I can confirm this happens on the SDK 29 emulator (with about a 50% failure rate for me) but not on the SDK 24 emulator. androidx.test 1.3.0 (Espresso 3.3.0)

seadowg commented 3 years ago

Also encountering this. Have not managed to find a robust solution other than adding a Thread.sleep. Waiting on visibility, clickablility etc doesn't help and putting in "try again on failure" style logic can cause problems. It feels like using a custom on click listener for dialogs that Espresso can inspect might be a workaround for whatever the real problem is but that'll be pretty invasive.

mtotschnig commented 2 years ago

I have also struggled with this bug and can add the observation, that when the failure occurs, the click seems to miss the dialogue, i.e. it is executed, but the event gets routed towards the activity window behind the dialog.

seadowg commented 2 years ago

@brettchabot is this being looked at all? This seems to be forcing people to (I definitely have had to) add Thread.sleep to their tests to get something reliable.

brettchabot commented 2 years ago

@adazh any thoughts?

adazh commented 2 years ago

Can you try using our latest Espresso release Espresso 3.5.0-alpha04?

Not sure if it could fix this issue, but it contains a fix that uses consistent input device source for event injection, which may be something relevant.

seadowg commented 2 years ago

I've run our suite a few times with 3.5.0-alpha04 against an API 29 and API 30 Pixel 2 on Firebase (without mitigation code disabled) and it does indeed seem to be more stable! It'd be interesting to see if others get similar results. I can investigate again once there is stable release of 3.5.0.

adazh commented 2 years ago

Nice! Thanks for confirming!

dabrosch commented 1 year ago

I am still seeing the problem after running on Espresso 3.5.0.

adazh commented 1 year ago

@dabrosch Can you also try with 3.5.0-alpha04? If the problem persists, please also upload a sample that we could repro the issue. Thanks.

dabrosch commented 1 year ago

I will have to extract out non-proprietary code to publish that, and I am not 100% sure I can repro it because the entire application is huge, and this failure is definitely related to a race condition, which means it is "touchy." But what I can 100% say is that without a 250 msec wait hack placed immediately after a popup is found to exist (via UiAutomator), the espresso click attempt fails 95% of the time.

brettchabot commented 1 year ago

FWIW I did some testing using the sample posted earlier: https://github.com/asavill/esspresso_alert_dialog_issue

Interestingly this sample (with espresso 3.1.1) can repro the problem 100% on an API 29 emulator, but the test passes reliably on every other API level (24, 28, 30,31,33) I've tried.

Using the latest stable release of all dependencies, including espresso (3.5.0) helps a little but the failure rate still seems > 50%). I tried adding logging and investigating more but didn't find anything yet

lsuski commented 1 year ago

I've created few workarounds in our internal library for dialog clicking:

lsuski commented 1 year ago

The other issue I faced rarely was due to View not being in touch mode. I faced this with sending KEYCODE_BACK as first event in a test. In such case motion down event is ignored

dabrosch commented 1 year ago

I can try the other versions though.

brettchabot commented 1 year ago

@lsuski do you mind providing more details how you are monitoring for dialog enter animation? I know Instrumentation.startActivitySync has logic to wait for activity animations to complete (which ActivityScenario now uses), but that logic doesn't seem possible to do as an app.

I would hope disabling animations would prevent dialog and activity animations from occurring in the first place but I suspect that might not be the case.

brettchabot commented 1 year ago

This line of code also in Instrumentation.startActivitySync seems potentially relevant: https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/app/Instrumentation.java;drc=0f9ea02be099947968936258e1f0746a43470a7c;l=568

Unfortunately I don't see a way of calling that outside of the android framework either on API 29. API 33 introduces a TransactionCommittedListener but that doesn't help here...

Another workaround that appeared to work on @asavill sample is to force a redraw on the dialog. Basically this logic here used for androidx.test screenshots .

lsuski commented 1 year ago

I'll share it on Monday. The code you mentioned is for Activity only started by test but during the test other activities can be started by app and this logic does not apply. Also afaik dialog has its own enter animation.

lsuski commented 1 year ago
private const val AWAIT_TIME = 120L
 override fun perform(uiController: UiController, view: View) {
        val rootView = view.rootView
        if (lastRootView?.get() != rootView) {
            lastRootView = rootView.toWeakRef()
            val waitTime = rootView.getAnimationTime() ?: AWAIT_TIME
            if (waitTime > 0) {
                val finalWait = max(waitTime, AWAIT_TIME)
                Log.i("RootAwaitAction", "loopMainThreadForAtLeast $finalWait")
                uiController.loopMainThreadForAtLeast(finalWait)
            }
        }
    }

    private fun View.getAnimationTime(): Long? {
        val layoutParams = EspressoExposer.listActiveRoots().first { it.decorView.rootView == this }.windowLayoutParams.get()
        if (layoutParams.windowAnimations != 0) {
            val array = context.obtainStyledAttributes(layoutParams.windowAnimations, intArrayOf(android.R.attr.windowEnterAnimation))
            val resourceId = array.getResourceId(0, -1)
            array.recycle()
            if (resourceId != -1) {
                val animDuration = AnimationUtils.loadAnimation(context, resourceId).duration
                val waitForAnimation = SystemClock.uptimeMillis() - drawingTime - animDuration
                return if (waitForAnimation < 0) {
                    -waitForAnimation
                } else 0
            }
        }
        return null
    }

This basically detects if this is new root than previous one and waits for 120ms or some remaining time calculated from enter animation duration

brettchabot commented 1 year ago

Thanks for the details, interesting approach. Correct me if I'm wrong but that solution appears to be essentially a conditional sleep, set to the value of animation duration.

I realize the limitations of the solutions I posted earlier, but unfortunately our options to work around this particular issue seem limited.

lsuski commented 1 year ago

@brettchabot yes, you're right

eduardbosch-jt commented 1 year ago

My workaround is to use the ConditionWatcher with a custom ViewInstruction that waits for a certain Espresso view condition to be met. Basically is a loop checking for a condition every X ms with a Thread.sleep.

This is the custom ViewInstruction for ConditionWatcher.

class ViewInstruction(
    private val viewMatcher: Matcher<View>,
    private val idleMatcher: Matcher<View>,
    private val rootMatcher: Matcher<Root> = RootMatchers.DEFAULT,
) : Instruction() {

    private var assertionThrowable: Throwable? = null

    override fun getDescription(): String {
        val stringBuilder = StringBuilder()
            .append(toString())
            .append(" ")
            .append(viewMatcher.description())

        assertionThrowable?.let { assertionThrowable ->
            stringBuilder
                .append(" ")
                .append("(${assertionThrowable.message ?: "$assertionThrowable"})")
        }

        val viewException = getExceptionWithViewHierarchy()
        stringBuilder
            .append("\nView hierarchy:\n")
            .append(viewException.stackTraceToString())

        return stringBuilder.toString()
    }

    override fun checkCondition(): Boolean =
        idleMatcher.matches(
            getView(viewMatcher),
        )

    private fun getView(
        viewMatcher: Matcher<View>,
    ): View? =
        try {
            var view: View? = null
            runOnUiThread {
                val viewInteraction = onView(viewMatcher).inRoot(rootMatcher)

                val finderField: Field = viewInteraction
                    .javaClass
                    .getDeclaredField("viewFinder").apply {
                        isAccessible = true
                    }

                val finder = finderField.get(viewInteraction) as ViewFinder
                view = finder.view
            }
            view
        } catch (e: Throwable) {
            assertionThrowable = e
            null
        }

    private fun getExceptionWithViewHierarchy(): Throwable =
        runCatching {
            assertDisplayed("Force non existent text to print hierarchy")
        }.exceptionOrNull()!!
}

These are some helper methods to wait for view conditions:

    fun waitUntilViewIsDisplayedInDialog(text: String) {
        waitUntilViewMatchesCondition(text, isDisplayed())
    }

    fun waitUntilViewIsDisplayedInDialog(viewStringId: Int) {
        waitUntilViewMatchesCondition(viewStringId, isDisplayed())
    }

    private fun waitUntilViewMatchesCondition(viewStringId: Int, condition: Matcher<View>) {
        ConditionWatcher.waitForCondition(
            ViewInstruction(
                viewStringId.resourceMatcher(),
                condition,
                isDialog(),
            ),
        )
    }

    private fun waitUntilViewMatchesCondition(text: String, condition: Matcher<View>) {
        ConditionWatcher.waitForCondition(
            ViewInstruction(
                withText(text),
                condition,
                isDialog(),
            ),
        )
    }

And this is how I use it with the robot pattern:

fun someDialog(
    func: SomeDialogRobot.() -> Unit,
): SomeDialogRobot {
    waitUntilViewIsDisplayedInDialog(R.string.some_string_on_the_dialog)
    return SomeDialogRobot().apply(func)
}

class SomeDialogRobot

That solves the dialog click 100% of the time in my tests.