package com.android.onboarding.bedsteadonboarding
import android.app.ActivityOptions
import android.content.ContentValues
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.graphics.Bitmap
import android.os.Build
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.services.storage.TestStorage
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import com.android.onboarding.bedsteadonboarding.annotations.OnboardingNodeScreenshot
import com.android.onboarding.bedsteadonboarding.annotations.TestNodes
import com.android.onboarding.bedsteadonboarding.contractutils.ContractExecutionEligibilityChecker
import com.android.onboarding.bedsteadonboarding.contractutils.ContractUtils
import com.android.onboarding.bedsteadonboarding.data.NodeData
import com.android.onboarding.bedsteadonboarding.fakes.FakeActivityNode
import com.android.onboarding.bedsteadonboarding.graph.OnboardingGraphProvider
import com.android.onboarding.bedsteadonboarding.logcat.LogcatReader
import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil
import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil.TEST_NODE_CLASS_COLUMN
import com.android.onboarding.bedsteadonboarding.utils.EventLogUtils.ONBOARDING_EVENT_LOG_TAG
import com.android.onboarding.contracts.ArgumentFreeOnboardingActivityApiContract
import com.android.onboarding.contracts.ArgumentFreeVoidOnboardingActivityApiContract
import com.android.onboarding.contracts.EXTRA_ONBOARDING_NODE_ID
import com.android.onboarding.contracts.OnboardingActivityApiContract
import com.android.onboarding.contracts.UNKNOWN_NODE_ID
import com.android.onboarding.contracts.VoidOnboardingActivityApiContract
import com.android.onboarding.contracts.annotations.OnboardingNode
import com.android.onboarding.contracts.annotations.OnboardingNodeMetadata
import com.android.onboarding.contracts.nodeId
import com.android.onboarding.nodes.OnboardingEvent
import com.android.onboarding.nodes.OnboardingGraph
import com.android.onboarding.nodes.OnboardingGraphLog
import java.lang.AssertionError
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
import java.time.Duration
import java.time.Instant
import kotlin.reflect.KClass
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* TestRule to run before and after each test run. It will store all the test node related configs
* such as which nodes are allowed to execute.
*/
class OnboardingTestsRule {
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private val instrumentationContext = InstrumentationRegistry.getInstrumentation().context
private val testStorage = TestStorage()
private lateinit var logcatReader: LogcatReader
private lateinit var testNodesConfiguration: TestNodesConfiguration
// Initial/ Source node of the graph. In a test multiple nodes could be launched or even a single
// node could be launched multiple times using [OnboardingTestRule#launch] call. Each such call
// would start a new graph and hence update [startNodeIdOfGraph] value.
private var startNodeIdOfGraph: Long = UNKNOWN_NODE_ID
private var hasOnboardingNodeScreenshotAnnotation: Boolean = false
private var hasTakenScreenshot: Boolean = false
private var currentTestName: String = ""
private var nameOfNodeToTakeScreenshot: String = ""
private var componentOfNodeToTakeScreenshot: String = ""
/**
* Initializes the states used by this test rule.
*
* This method should not be called directly. It is invoked internally by the [DeviceState]
* rule that is part of the Bedstead project.
*/
fun init(description: Description) {
if (description.isTest) {
Log.e(TAG, "Setting up OnboardingTestsRule")
currentTestName = description.methodName
// Extract test configuration from all the annotations applied to the test.
testNodesConfiguration = extractTestNodesConfiguration(description)
// Start Reading OnboardingEvents Logs.
logcatReader = LogcatReader(filterTag = ONBOARDING_EVENT_LOG_TAG).apply { start() }
// Create an array of [ContentValues] representing the Test Configs.
val contentValuesForTestConfigs =
createContentValuesForAllowedNodes(testNodesConfiguration.allowedNodes)
/* Now that we have an array of [allowedNodes] present in [contentValuesForTestConfigs],
store them in all the apps in [appsStoringTestConfig] using their [TestContentProvider].
*/
for (appPackageName in testNodesConfiguration.appsStoringTestConfig) {
insertTestConfigsInApp(contentValuesForTestConfigs, appPackageName)
}
}
}
/**
* Tears down the states used by this test rule.
*
* This method should not be called directly. It is invoked internally by the [DeviceState]
* rule that is part of the Bedstead project.
*/
fun teardown() {
Log.e(TAG, "Tearing down OnboardingTestsRule")
logcatReader.stopReadingLogs()
deleteAllTestConfigs(testNodesConfiguration.appsStoringTestConfig)
if (hasOnboardingNodeScreenshotAnnotation && !hasTakenScreenshot) {
throw AssertionError("OnboardingTestRule#takeScreenshot() not called in this test.")
}
}
var graph: OnboardingGraphProvider? = null
/** Provides access to the onboarding graph to be used to query for events. */
fun graph(): OnboardingGraphProvider {
if (graph == null) {
graph = OnboardingGraphProvider(logcatReader)
}
return graph!!
}
/** Creates a fake activity node from given node contract [nodeToFake]. */
fun fake(
nodeToFake: KClass>
): FakeActivityNode {
val appsStoringFakeNodeConfig = testNodesConfiguration.appsStoringTestConfig.toMutableSet()
appsStoringFakeNodeConfig.add(extractNodeDataAndItsAppPackage(nodeToFake).second)
return FakeActivityNode(
appsStoringFakeNodeConfig = appsStoringFakeNodeConfig.toSet(),
activityNode = nodeToFake,
context = instrumentationContext,
)
}
/**
* Launches an activity using [OnboardingActivityApiContract]. It will wait indefinitely until the
* activity starts. Each call to this method will start a new graph.
*/
fun launchAndWaitForNodeToStart(
activityContract: OnboardingActivityApiContract,
args: I,
) {
ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
instrumentationContext,
activityContract::class.java,
)
val nodeIntent = createNodeIntent(activityContract, args)
startTrampolineActivity(activityContract, nodeIntent)
}
/**
* Launches an activity using [ArgumentFreeOnboardingActivityApiContract]. It will wait
* indefinitely until the activity starts. Each call to this method will start a new graph.
*/
fun launchAndWaitForNodeToStart(
activityContract: ArgumentFreeOnboardingActivityApiContract
) {
ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
instrumentationContext,
activityContract::class.java,
)
val nodeIntent = createNodeIntent(activityContract, Unit)
startTrampolineActivity(activityContract, nodeIntent)
}
/**
* Launches an activity which do not return result using [VoidOnboardingActivityApiContract] and
* contract arguments. It will wait indefinitely until the activity starts. Each call to this
* method will start a new graph.
*/
fun launchAndWaitForNodeToStart(
activityContract: VoidOnboardingActivityApiContract,
args: I,
) {
ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
instrumentationContext,
activityContract::class.java,
)
val nodeIntent = createNodeIntent(activityContract, args)
startTrampolineActivity(activityContract, nodeIntent)
}
/**
* Launches an activity which do not return result using
* [ArgumentFreeVoidOnboardingActivityApiContract]. It will wait indefinitely until the activity
* starts. Each call to this method will start a new graph.
*/
fun launchAndWaitForNodeToStart(activityContract: ArgumentFreeVoidOnboardingActivityApiContract) {
ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
instrumentationContext,
activityContract::class.java,
)
val nodeIntent = createNodeIntent(activityContract, Unit)
startTrampolineActivity(activityContract, nodeIntent)
}
/** Captures the current UI on the device and saves it to the test output file. */
fun takeScreenshot() {
Log.i(TAG, "Taking screenshot of node $nameOfNodeToTakeScreenshot")
hasTakenScreenshot = true
val screenshot = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
val outputStream = testStorage.openOutputFile(/* pathname= */ getScreenshotName())
// Convert the bitmap to PNG.
screenshot.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outputStream)
// Adding node info to the metadata of the generated sponge.
testStorage.addOutputProperties(
mapOf(
"onboarding_screenshot_node" to nameOfNodeToTakeScreenshot,
"onboarding_screenshot_component" to componentOfNodeToTakeScreenshot,
)
)
}
/**
* Given the [contractClass] of node, it blocks the test until the node has completed execution.
* Currently it supports only activity nodes. Other node types will be handled later. There are 3
* scenarios for node completion for activities.
* 1. For nodes which return result, until that node has returned result i.e.,
* [ActivityNodeSetResult] event has been logged.
* 2. Some other node has been started. This is a proxy for representing that the node of interest
* has finished execution.
* 3. User-initiated exits like lifecycle events representing destroy, backpress, etc.
*/
fun blockUntilNodeHasFinished(contractClass: KClass<*>) {
// Find contract identifier of the node of interest given its contract class.
val contractIdentifier = ContractUtils.getContractIdentifier(contractClass.java)
while (true) {
val graph = OnboardingGraph(getOnboardingEvents())
if (hasNodeFinishedExecution(contractIdentifier, graph)) return
// In the logcat, we have not received OnboardingEvent for given node, so wait for some
// time before checking again.
Thread.sleep(1000)
}
}
/**
* Given the [contractIdentifierOfNodeOfInterest] it queries the given [graph] to check if this
* node has finished execution.
*/
internal fun hasNodeFinishedExecution(
contractIdentifierOfNodeOfInterest: String,
graph: OnboardingGraph,
): Boolean {
// Find the [OnboardingGraph.Node] entry for the source node of graph.
val startNodes = graph.nodes.entries.filter { it.key == startNodeIdOfGraph }
if (startNodes.isEmpty()) return false
val startNode = startNodes.first()
val nodeIdOfInterest =
findEarliestStartedNodeWithGivenContract(
startNode.key,
graph,
contractIdentifierOfNodeOfInterest,
) ?: return false
val nodeToWaitFor = graph.nodes[nodeIdOfInterest]!!
val events = nodeToWaitFor.events
// Unblock, once the node completion condition has reached.
return events.any {
it.source is OnboardingEvent.ActivityNodeFinished ||
isAnotherNodeAttemptedToBeExecutedForResult(it.source, contractIdentifierOfNodeOfInterest)
} || nodeToWaitFor.outgoingEdgesOfValidNodes.isNotEmpty()
}
/**
* This will do a bfs traversal of the given [graph] whose initial node is [startNodeId]. It will
* look for all nodes whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. Out of
* those it will return the one which was started first. If there is no node with
* contractIdentifier as [contractIdentifierOfNodeOfInterest] it will return null.
*/
internal fun findEarliestStartedNodeWithGivenContract(
startNodeId: Long,
graph: OnboardingGraph,
contractIdentifierOfNodeOfInterest: String,
): Long? {
val queue = ArrayDeque().apply { add(startNodeId) } // Add initial node
val visitedNodes = mutableSetOf()
var minStartTimeOfNodeOfInterest: Instant = Instant.MAX
var probableNodeOfInterest: Long? = null
while (queue.isNotEmpty()) {
val currentNode = graph.nodes[queue.removeFirst()]!!
visitedNodes.add(currentNode.id)
if (
ContractUtils.getContractIdentifierForNode(currentNode) ==
contractIdentifierOfNodeOfInterest && currentNode.start < minStartTimeOfNodeOfInterest
) {
probableNodeOfInterest = currentNode.id
minStartTimeOfNodeOfInterest = currentNode.start
}
for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
if (!visitedNodes.contains(outgoingNode.node.id)) {
queue.add(outgoingNode.node.id)
}
}
}
return probableNodeOfInterest
}
internal fun getStartNodeIdOfGraph() = startNodeIdOfGraph
// Internal helper function to be used only in robolectric tests.
internal fun setStartNodeIdOfGraph(nodeId: Long) {
if (requireRobolectric()) {
startNodeIdOfGraph = nodeId
} else {
throw IllegalAccessException("Not allowed to access internal methods")
}
}
/**
* Creates an [Intent] to launch the node for contract [activityContract] using [contractArgs].
*/
internal fun createNodeIntent(
activityContract: OnboardingActivityApiContract,
contractArgs: I,
): Intent {
// Use reflection to invoke the [OnboardingActivityApiContract#performCreateIntent()] of
// [activityContract] to create [Intent]. We assume that the instrumented app is not proguarded.
val intentCreationMethod =
activityContract::class.java.methods.firstOrNull {
it.name == INTENT_CREATION_METHOD_NAME && it.parameterCount == 2
}
if (intentCreationMethod != null) {
intentCreationMethod.isAccessible = true
val nodeIntent =
intentCreationMethod.invoke(activityContract, instrumentationContext, contractArgs)
as? Intent ?: error("Unable to create valid Intent for contract $activityContract")
// Since we start the node for [activityContract] using startActivity from
// [TrampolineActivity], so the graph is broken, i.e. we can't know what is the nodeId of the
// [activityContract]. So as a workaround we set the nodeId in the [Intent] here itself.
startNodeIdOfGraph = nodeIntent.nodeId
Log.i(TAG, "Activity will be launched with nodeId $startNodeIdOfGraph")
nodeIntent.putExtra(EXTRA_ONBOARDING_NODE_ID, startNodeIdOfGraph)
return nodeIntent
} else {
throw IllegalStateException(
"Couldn't find $INTENT_CREATION_METHOD_NAME method for contract $activityContract"
)
}
}
/**
* Given the [contractIdentifierOfNodeOfInterest] of the activity node, find if it has started by
* querying the onboarding events [graph] for [ActivityNodeResumed] event. If the
* [ActivityNodeResumed] event is logged for the node then it means that the activity has started
* and is visible.
*/
internal fun hasActivityNodeStarted(
contractIdentifierOfNodeOfInterest: String,
graph: OnboardingGraph,
): Boolean {
// Find the [OnboardingGraph.Node] entry for the source node of graph.
val startNode = graph.nodes.entries.find { it.key == startNodeIdOfGraph } ?: return false
return hasEventOccurred(
OnboardingEvent.ActivityNodeResumed::class,
contractIdentifierOfNodeOfInterest,
startNode.key,
graph,
)
}
/**
* Returns if the event represented by its class [eventClassOfInterest] has occurred for the node
* whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. This will do a bfs traversal
* of the given [graph] whose initial node is [startNodeId].
*/
internal fun hasEventOccurred(
eventClassOfInterest: KClass<*>,
contractIdentifierOfNodeOfInterest: String,
startNodeId: Long,
graph: OnboardingGraph,
): Boolean {
val queue = ArrayDeque().apply { add(startNodeId) } // Add initial node
val visitedNodes = mutableSetOf()
while (queue.isNotEmpty()) {
val currentNode = graph.nodes.getValue(queue.removeFirst())
visitedNodes.add(currentNode.id)
if (
ContractUtils.getContractIdentifierForNode(currentNode) ==
contractIdentifierOfNodeOfInterest &&
currentNode.spawnedEvents.any { it.source::class == eventClassOfInterest }
) {
return true
}
for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
if (!visitedNodes.contains(outgoingNode.node.id)) {
queue.add(outgoingNode.node.id)
}
}
}
return false
}
/**
* Checks if the activity launched has been validated using the correct contract with which it was
* launched. If not it will throw an error. Note that it will also throw an error if the activity
* was validated twice once using correct and another time using incorrect correct.
*/
internal fun throwErrorIfActivityNotValidatedUsingCorrectContract(
onboardingEvents: Set,
contractIdentifierOfInterest: String,
) {
val invalidEvent =
onboardingEvents.find {
isInvalidActivityNodeValidatingEvent(it, contractIdentifierOfInterest, startNodeIdOfGraph)
}
if (invalidEvent != null) {
error(
"Please use the correct contract to validate the launched activity. Invalid event is $invalidEvent"
)
}
}
private fun isInvalidActivityNodeValidatingEvent(
event: OnboardingEvent,
expectedContractIdentifier: String,
activityNodeId: Long,
) =
event.nodeId == activityNodeId &&
event is OnboardingEvent.ActivityNodeValidating &&
ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
expectedContractIdentifier
/** Returns if the calling function is executed on robolectric. */
private fun requireRobolectric() = "robolectric" == Build.FINGERPRINT
/**
* Stores the test configs using the ContentProvider of the app with given [appPackageName].
*
* @param contentValues An array of ContentValues representing the test configurations.
* @param appPackageName package name of the app where test configs would be stored.
*/
private fun insertTestConfigsInApp(contentValues: Array, appPackageName: String) {
/* Delete test configurations stored by app before inserting new ones. */
deleteTestConfigsOfApp(appPackageName)
val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(appPackageName))
instrumentationContext.contentResolver.bulkInsert(uri, contentValues)
}
/** Returns the immutable set of OnboardingEvents read from logs */
private fun getOnboardingEvents(): Set {
val onboardingEvents = mutableSetOf()
for (logLines in logcatReader.getFilteredLogs()) {
// Is an onboarding graph line containing OnboardingEvent.
val encoded = logLines.split("${ONBOARDING_EVENT_LOG_TAG}: ")[1]
// Deserialize and store the OnboardingEvent in-memory.
val event = OnboardingEvent.deserialize(encoded)
onboardingEvents.add(event)
}
return onboardingEvents.toSet()
}
/**
* Returns [true] if a node other than the one with name [contractIdentifierOfNode], has been
* started for result
*/
private fun isAnotherNodeAttemptedToBeExecutedForResult(
event: OnboardingEvent,
contractIdentifierOfNode: String,
) =
(event is OnboardingEvent.ActivityNodeStartExecuteSynchronously) &&
(ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
contractIdentifierOfNode)
/**
* Start the [TrampolineActivity] in the app owning [activityContract]. The [Intent] for
* [TrampolineActivity] will also contain the [nodeIntent] to launch the actual intended node for
* [activityContract] as an extra.
*/
private fun startTrampolineActivity(
activityContract: OnboardingActivityApiContract<*, *>,
nodeIntent: Intent,
) {
val trampolineActivityIntent =
Intent(getTrampolineActivityIntentAction(activityContract)).apply {
putExtra(EXTRA_NODE_START_INTENT_KEY, nodeIntent)
flags = INTENT_FLAGS_START_IN_NEW_TASK
}
val activityOptions =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions.makeBasic().apply { isShareIdentityEnabled = true }
} else {
null
}
// Start activity and wait.
instrumentationContext.startActivity(trampolineActivityIntent, activityOptions?.toBundle())
waitForActivityLaunch(activityContract)
}
/** Get the [Intent] action to launch Trampoline Activity of the application owning [contract]. */
private fun getTrampolineActivityIntentAction(
contract: OnboardingActivityApiContract<*, *>
): String {
val onboardingNode =
extractOnboardingNodeAnnotation(contract)
?: error("Can't fetch OnboardingNode annotation for contract $contract")
val onboardingNodeMetadata = getOnboardingNodeMetadata(onboardingNode)
return "${onboardingNodeMetadata.packageName}${TRAMPOLINE_ACTIVITY_NAME}"
}
/**
* Extract the [OnboardingNode] annotation applied to activity [contract]. There should be exactly
* 1 such annotation and if there is none then null is returned.
*/
private fun extractOnboardingNodeAnnotation(contract: OnboardingActivityApiContract<*, *>) =
contract::class.annotations.filterIsInstance().firstOrNull()
/**
* From the [TestNodes] annotation, extract all the nodes which are allowed to execute and also
* the package name of the apps containing the nodes and store them in [TestNodesConfiguration].
*/
private fun processTestNodesAnnotation(testNodes: TestNodes): TestNodesConfiguration {
val allowedNodes = mutableListOf()
val appsStoringTestConfig = mutableSetOf(instrumentationContext.packageName)
for (node in testNodes.nodes) {
extractNodeDataAndItsAppPackage(node.contract).apply {
allowedNodes.add(first)
appsStoringTestConfig.add(second)
}
}
return TestNodesConfiguration(appsStoringTestConfig.toSet(), allowedNodes.toList())
}
/**
* From the [OnboardingNodeScreenshot] annotation, extract the allowed node to execute and also
* the package name of the app containing this node and store them in [TestNodesConfiguration].
*/
private fun processOnboardingNodeScreenshotAnnotation(
onboardingNodeScreenshot: OnboardingNodeScreenshot
): TestNodesConfiguration {
validateUiNode(onboardingNodeScreenshot.node.contract.annotations)
hasOnboardingNodeScreenshotAnnotation = true
var allowedNode: NodeData
val appsStoringTestConfig = mutableSetOf(instrumentationContext.packageName)
extractNodeDataAndItsAppPackage(onboardingNodeScreenshot.node.contract).apply {
allowedNode = first
appsStoringTestConfig.add(second)
}
return TestNodesConfiguration(appsStoringTestConfig.toSet(), listOf(allowedNode))
}
private fun validateUiNode(annotations: List) {
for (annotation in annotations) {
if (annotation is OnboardingNode) {
when (annotation.uiType) {
OnboardingNode.UiType.EDUCATION,
OnboardingNode.UiType.INPUT,
OnboardingNode.UiType.LOADING,
OnboardingNode.UiType.ERROR,
OnboardingNode.UiType.OTHER -> {
nameOfNodeToTakeScreenshot = annotation.name
componentOfNodeToTakeScreenshot = annotation.component
}
OnboardingNode.UiType.HOST,
OnboardingNode.UiType.NONE,
OnboardingNode.UiType.INVISIBLE ->
throw IllegalArgumentException(
"Not allowed to take screenshot for non-UI onboarding nodes."
)
}
}
}
}
/**
* Given a node's contract class, returns its [NodeData] representation and the package name of
* the app to which it belongs.
*/
internal fun extractNodeDataAndItsAppPackage(nodeContract: KClass<*>): Pair {
for (annotation in nodeContract.annotations) {
if (annotation is OnboardingNode) {
val onboardingNodeMetadata = getOnboardingNodeMetadata(annotation)
return Pair(
NodeData(
allowedContractIdentifier = ContractUtils.getContractIdentifier(nodeContract.java)
),
onboardingNodeMetadata.packageName,
)
}
}
error("$nodeContract class does not have OnboardingNode annotation")
}
private fun getOnboardingNodeMetadata(onboardingNode: OnboardingNode): OnboardingNodeMetadata {
val nodeMetadata = onboardingNode.component.split("/")
return when (nodeMetadata.size) {
1 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[0])
2 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[1])
else -> error("OnboardingNode component ${onboardingNode.component} is invalid")
}
}
// Creates a list of ContentValues representing the list of allowedNodes.
private fun createContentValuesForAllowedNodes(
allowedNodes: List
): Array =
allowedNodes
.map { node ->
ContentValues().apply { put(TEST_NODE_CLASS_COLUMN, node.allowedContractIdentifier) }
}
.toTypedArray()
// Deletes the test configs stored all the apps listed in [appsStoringTestConfig] .
private fun deleteAllTestConfigs(appsStoringTestConfig: Set) {
for (appPackageName in appsStoringTestConfig) {
deleteTestConfigsOfApp(appPackageName)
}
}
// Deletes the test configs stored by a given app.
private fun deleteTestConfigsOfApp(packageName: String) {
val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(packageName))
instrumentationContext.contentResolver.delete(uri, /* where= */ null, /* selectionArgs= */ null)
}
/**
* Extracts the [TestNodesConfiguration] from all annotations applied to [description].
*
* If no annotation is provided, then null will be returned.
*/
private fun extractTestNodesConfiguration(description: Description): TestNodesConfiguration {
val testNodesAnnotationConfiguration =
extractTestNodesAnnotation(description)?.let { processTestNodesAnnotation(it) }
?: TestNodesConfiguration()
val onboardingNodeScreenshotAnnotationTestConfiguration =
extractOnboardingNodeScreenshotAnnotation(description)?.let {
processOnboardingNodeScreenshotAnnotation(it)
} ?: TestNodesConfiguration()
return TestNodesConfiguration(
appsStoringTestConfig =
testNodesAnnotationConfiguration.appsStoringTestConfig +
onboardingNodeScreenshotAnnotationTestConfiguration.appsStoringTestConfig,
allowedNodes =
testNodesAnnotationConfiguration.allowedNodes +
onboardingNodeScreenshotAnnotationTestConfiguration.allowedNodes,
)
}
/**
* Extracts the [TestNodes] annotation applied to [description].
*
* There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
* returned.
*/
private fun extractTestNodesAnnotation(description: Description) =
description.annotations?.filterIsInstance()?.firstOrNull()
/**
* Extracts the [OnboardingNodeScreenshot] annotation applied to [description].
*
* There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
* returned.
*/
private fun extractOnboardingNodeScreenshotAnnotation(description: Description) =
description.annotations?.filterIsInstance()?.firstOrNull()
/**
* Returns the screenshot path derived from the component name [componentOfNodeToTakeScreenshot],
* node name [nameOfNodeToTakeScreenshot] and test method name [currentTestName].
*/
private fun getScreenshotName() =
"$componentOfNodeToTakeScreenshot/$nameOfNodeToTakeScreenshot/${currentTestName}.png"
private fun waitForActivityLaunch(activityContract: OnboardingActivityApiContract<*, *>) {
if (!requireRobolectric()) {
val contractIdentifier = ContractUtils.getContractIdentifier(activityContract::class.java)
val contractPackageName = extractNodeDataAndItsAppPackage(activityContract::class).second
while (true) {
val onboardingEvents = getOnboardingEvents()
throwErrorIfActivityNotValidatedUsingCorrectContract(onboardingEvents, contractIdentifier)
val graph = OnboardingGraph(onboardingEvents)
if (hasActivityNodeStarted(contractIdentifier, graph)) {
// Pause the test for up to 60 seconds, waiting for a top-level UI element (depth 0) from
// the package specified by [contractPackageName] to appear on the screen.
device.wait(
Until.hasObject(By.pkg(contractPackageName).depth(0)),
Duration.ofSeconds(60).toMillis(),
)
Thread.sleep(1000) // Wait an additional second in case UI automator is flaky.
return
}
Thread.sleep(100) // Check every 100 milliseconds.
}
}
}
/**
* Stores the test configuration obtained by processing test level annotations. This data will be
* later used to store the list of [allowedNodes] in all the apps given by [appsStoringTestConfig]
* using their [TestContentProvider].
*/
private data class TestNodesConfiguration(
/**
* Stores the package name of apps where the test configs will be stored. This includes the app
* package name of allowed nodes and the instrumented app.
*/
val appsStoringTestConfig: Set = setOf(),
/** Stores the list of nodes which are allowed to execute in tests. */
val allowedNodes: List = listOf(),
)
companion object {
const val TAG = "OnboardingTestsRule"
const val EXTRA_NODE_START_INTENT_KEY =
"com.android.onboarding.bedsteadonboarding.activities.extra.NODE_START_INTENT"
private const val INTENT_CREATION_METHOD_NAME = "performCreateIntent"
private const val INTENT_FLAGS_START_IN_NEW_TASK =
FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK
// LINT.IfChange(activity_action_name)
private const val TRAMPOLINE_ACTIVITY_NAME = ".bedstead.onboarding.trampolineactivity"
// LINT.ThenChange(java/com/android/onboarding/bedsteadonboarding/AndroidManifest.xml:activity_action_name)
}
}