AndroidComposeTestRule.kt

/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.test.junit4

import androidx.activity.ComponentActivity
import androidx.compose.foundation.text.blinkingCursorEnabled
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Recomposer
import androidx.compose.ui.platform.setContent
import androidx.compose.ui.test.ExperimentalTesting
import androidx.compose.ui.test.InternalTestingApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.SemanticsNodeInteractionCollection
import androidx.compose.ui.test.createTestContext
import androidx.compose.ui.test.junit4.android.AndroidOwnerRegistry
import androidx.compose.ui.test.junit4.android.FirstDrawRegistry
import androidx.compose.ui.test.junit4.android.IdleAwaiter
import androidx.compose.ui.test.junit4.android.registerComposeWithEspresso
import androidx.compose.ui.test.junit4.android.unregisterComposeFromEspresso
import androidx.compose.ui.text.InternalTextApi
import androidx.compose.ui.text.input.textInputServiceFactory
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.test.ext.junit.rules.ActivityScenarioRule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

actual fun createComposeRule(): ComposeTestRule = createAndroidComposeRule<ComponentActivity>()

/**
 * Factory method to provide android specific implementation of [createComposeRule], for a given
 * activity class type [A].
 *
 * This method is useful for tests that require a custom Activity. This is usually the case for
 * app tests. Make sure that you add the provided activity into your app's manifest file (usually
 * in main/AndroidManifest.xml).
 *
 * This creates a test rule that is using [ActivityScenarioRule] as the activity launcher. If you
 * would like to use a different one you can create [AndroidComposeTestRule] directly and supply
 * it with your own launcher.
 *
 * If you don't care about specific activity and just want to test composables in general, see
 * [createComposeRule].
 */
inline fun <reified A : ComponentActivity> createAndroidComposeRule():
    AndroidComposeTestRule<ActivityScenarioRule<A>, A> {
        // TODO(b/138993381): By launching custom activities we are losing control over what content is
        //  already there. This is issue in case the user already set some compose content and decides
        //  to set it again via our API. In such case we won't be able to dispose the old composition.
        //  Other option would be to provide a smaller interface that does not expose these methods.
        return createAndroidComposeRule(A::class.java)
    }

/**
 * Factory method to provide android specific implementation of [createComposeRule], for a given
 * [activityClass].
 *
 * This method is useful for tests that require a custom Activity. This is usually the case for
 * app tests. Make sure that you add the provided activity into your app's manifest file (usually
 * in main/AndroidManifest.xml).
 *
 * This creates a test rule that is using [ActivityScenarioRule] as the activity launcher. If you
 * would like to use a different one you can create [AndroidComposeTestRule] directly and supply
 * it with your own launcher.
 *
 * If you don't care about specific activity and just want to test composables in general, see
 * [createComposeRule].
 */
fun <A : ComponentActivity> createAndroidComposeRule(
    activityClass: Class<A>
): AndroidComposeTestRule<ActivityScenarioRule<A>, A> =
    @OptIn(ExperimentalTesting::class)
    createAndroidComposeRule(
        activityClass = activityClass,
        driveClockByMonotonicFrameClock = false
    )

/**
 * Factory method to provide an implementation of [createComposeRule] that installs an animation
 * clock that is driven by the MonotonicFrameClock instead of the Choreographer. This is highly
 * experimental and _will_ be removed in the future. See the other overloads of
 * [createAndroidComposeRule] for the recommended way of creating a [ComposeTestRule].
 */
@ExperimentalTesting
internal fun createAndroidComposeRule(
    driveClockByMonotonicFrameClock: Boolean
): AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity> {
    return createAndroidComposeRule(ComponentActivity::class.java, driveClockByMonotonicFrameClock)
}

@ExperimentalTesting
private fun <A : ComponentActivity> createAndroidComposeRule(
    activityClass: Class<A>,
    driveClockByMonotonicFrameClock: Boolean
): AndroidComposeTestRule<ActivityScenarioRule<A>, A> = AndroidComposeTestRule(
    activityRule = ActivityScenarioRule(activityClass),
    activityProvider = { it.getActivity() },
    driveClockByMonotonicFrameClock = driveClockByMonotonicFrameClock
)

/**
 * Android specific implementation of [ComposeTestRule].
 *
 * This rule wraps around the given [activityRule], which is responsible for launching the activity.
 * The [activityProvider] should return the launched activity instance when the [activityRule] is
 * passed to it. In this way, you can provide any test rule that can launch an activity
 *
 * @param activityRule Test rule to use to launch the activity.
 * @param activityProvider To resolve the activity from the given test rule. Must be a blocking
 * function.
 */
@OptIn(InternalTestingApi::class)
class AndroidComposeTestRule<R : TestRule, A : ComponentActivity>
@ExperimentalTesting
internal constructor(
    val activityRule: R,
    private val activityProvider: (R) -> A,
    driveClockByMonotonicFrameClock: Boolean
) : ComposeTestRule {

    @OptIn(ExperimentalTesting::class)
    constructor(
        activityRule: R,
        activityProvider: (R) -> A
    ) : this(activityRule, activityProvider, false)

    @ExperimentalTesting
    override val clockTestRule: AnimationClockTestRule =
        if (!driveClockByMonotonicFrameClock) {
            AndroidAnimationClockTestRule()
        } else {
            MonotonicFrameClockTestRule()
        }

    internal var disposeContentHook: (() -> Unit)? = null

    private val idleAwaiter = IdleAwaiter()
    private val testOwner = AndroidTestOwner(idleAwaiter)
    private val testContext = createTestContext(testOwner)

    private var activity: A? = null

    override val density: Density by lazy {
        // Using a cached activity is fine for density
        if (activity == null) {
            activity = activityProvider(activityRule)
        }
        Density(activity!!.resources.displayMetrics.density)
    }

    override val displaySize by lazy {
        // Using a cached activity is fine for display size
        if (activity == null) {
            activity = activityProvider(activityRule)
        }
        activity!!.resources.displayMetrics.let {
            IntSize(it.widthPixels, it.heightPixels)
        }
    }

    override fun apply(base: Statement, description: Description?): Statement {
        @Suppress("NAME_SHADOWING")
        @OptIn(ExperimentalTesting::class)
        return RuleChain
            .outerRule(clockTestRule)
            .around { base, _ -> AndroidComposeStatement(base) }
            .around(activityRule)
            .apply(base, description)
    }

    /**
     * @throws IllegalStateException if called more than once per test.
     */
    @SuppressWarnings("SyntheticAccessor")
    override fun setContent(composable: @Composable () -> Unit) {
        check(disposeContentHook == null) {
            "Cannot call setContent twice per test!"
        }

        // We always make sure we have the latest activity when setting a content
        activity = activityProvider(activityRule)

        runOnUiThread {
            val composition = activity!!.setContent(
                Recomposer.current(),
                composable
            )
            disposeContentHook = {
                composition.dispose()
            }
        }

        if (!isOnUiThread()) {
            // Only wait for idleness if not on the UI thread. If we are on the UI thread, the
            // caller clearly wants to keep tight control over execution order, so don't go
            // executing future tasks on the main thread.
            waitForIdle()
        }
    }

    override fun waitForIdle() {
        idleAwaiter.waitForIdle()
    }

    @ExperimentalTesting
    override suspend fun awaitIdle() {
        idleAwaiter.awaitIdle()
    }

    override fun <T> runOnUiThread(action: () -> T): T {
        return testOwner.runOnUiThread(action)
    }

    override fun <T> runOnIdle(action: () -> T): T {
        // Method below make sure that compose is idle.
        waitForIdle()
        // Execute the action on ui thread in a blocking way.
        return runOnUiThread(action)
    }

    inner class AndroidComposeStatement(
        private val base: Statement
    ) : Statement() {
        @OptIn(InternalTextApi::class)
        override fun evaluate() {
            @Suppress("DEPRECATION_ERROR")
            val oldTextInputFactory = textInputServiceFactory
            beforeEvaluate()
            try {
                base.evaluate()
            } finally {
                afterEvaluate()
                @Suppress("DEPRECATION_ERROR")
                textInputServiceFactory = oldTextInputFactory
            }
        }

        @OptIn(InternalTextApi::class)
        private fun beforeEvaluate() {
            @Suppress("DEPRECATION_ERROR")
            blinkingCursorEnabled = false
            AndroidOwnerRegistry.setupRegistry()
            FirstDrawRegistry.setupRegistry()
            registerComposeWithEspresso()
            @Suppress("DEPRECATION_ERROR")
            textInputServiceFactory = {
                TextInputServiceForTests(it)
            }
        }

        @OptIn(InternalTextApi::class)
        private fun afterEvaluate() {
            @Suppress("DEPRECATION_ERROR")
            blinkingCursorEnabled = true
            AndroidOwnerRegistry.tearDownRegistry()
            FirstDrawRegistry.tearDownRegistry()
            unregisterComposeFromEspresso()
            // Dispose the content
            if (disposeContentHook != null) {
                runOnUiThread {
                    // NOTE: currently, calling dispose after an exception that happened during
                    // composition is not a safe call. Compose runtime should fix this, and then
                    // this call will be okay. At the moment, however, calling this could
                    // itself produce an exception which will then obscure the original
                    // exception. To fix this, we will just wrap this call in a try/catch of
                    // its own
                    try {
                        disposeContentHook!!()
                    } catch (e: Exception) {
                        // ignore
                    }
                    disposeContentHook = null
                }
            }
            activity = null
        }
    }

    override fun onNode(
        matcher: SemanticsMatcher,
        useUnmergedTree: Boolean
    ): SemanticsNodeInteraction {
        return SemanticsNodeInteraction(testContext, useUnmergedTree, matcher)
    }

    override fun onAllNodes(
        matcher: SemanticsMatcher,
        useUnmergedTree: Boolean
    ): SemanticsNodeInteractionCollection {
        return SemanticsNodeInteractionCollection(testContext, useUnmergedTree, matcher)
    }
}

private fun <A : ComponentActivity> ActivityScenarioRule<A>.getActivity(): A {
    var activity: A? = null
    scenario.onActivity { activity = it }
    if (activity == null) {
        throw IllegalStateException("Activity was not set in the ActivityScenarioRule!")
    }
    return activity!!
}