<lambda>null1 package com.android.onboarding.bedsteadonboarding
2 
3 import android.app.ActivityOptions
4 import android.content.ContentValues
5 import android.content.Intent
6 import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
7 import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
8 import android.graphics.Bitmap
9 import android.os.Build
10 import android.util.Log
11 import androidx.test.platform.app.InstrumentationRegistry
12 import androidx.test.services.storage.TestStorage
13 import androidx.test.uiautomator.By
14 import androidx.test.uiautomator.UiDevice
15 import androidx.test.uiautomator.Until
16 import com.android.onboarding.bedsteadonboarding.annotations.OnboardingNodeScreenshot
17 import com.android.onboarding.bedsteadonboarding.annotations.TestNodes
18 import com.android.onboarding.bedsteadonboarding.contractutils.ContractExecutionEligibilityChecker
19 import com.android.onboarding.bedsteadonboarding.contractutils.ContractUtils
20 import com.android.onboarding.bedsteadonboarding.data.NodeData
21 import com.android.onboarding.bedsteadonboarding.fakes.FakeActivityNode
22 import com.android.onboarding.bedsteadonboarding.graph.OnboardingGraphProvider
23 import com.android.onboarding.bedsteadonboarding.logcat.LogcatReader
24 import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil
25 import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil.TEST_NODE_CLASS_COLUMN
26 import com.android.onboarding.bedsteadonboarding.utils.EventLogUtils.ONBOARDING_EVENT_LOG_TAG
27 import com.android.onboarding.contracts.ArgumentFreeOnboardingActivityApiContract
28 import com.android.onboarding.contracts.ArgumentFreeVoidOnboardingActivityApiContract
29 import com.android.onboarding.contracts.EXTRA_ONBOARDING_NODE_ID
30 import com.android.onboarding.contracts.OnboardingActivityApiContract
31 import com.android.onboarding.contracts.UNKNOWN_NODE_ID
32 import com.android.onboarding.contracts.VoidOnboardingActivityApiContract
33 import com.android.onboarding.contracts.annotations.OnboardingNode
34 import com.android.onboarding.contracts.annotations.OnboardingNodeMetadata
35 import com.android.onboarding.contracts.nodeId
36 import com.android.onboarding.nodes.OnboardingEvent
37 import com.android.onboarding.nodes.OnboardingGraph
38 import com.android.onboarding.nodes.OnboardingGraphLog
39 import java.lang.AssertionError
40 import java.lang.IllegalArgumentException
41 import java.lang.IllegalStateException
42 import java.time.Duration
43 import java.time.Instant
44 import kotlin.reflect.KClass
45 import org.junit.rules.TestRule
46 import org.junit.runner.Description
47 import org.junit.runners.model.Statement
48 /**
49  * TestRule to run before and after each test run. It will store all the test node related configs
50  * such as which nodes are allowed to execute.
51  */
52 class OnboardingTestsRule {
53 
54   val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
55 
56   private val instrumentationContext = InstrumentationRegistry.getInstrumentation().context
57   private val testStorage = TestStorage()
58 
59   private lateinit var logcatReader: LogcatReader
60   private lateinit var testNodesConfiguration: TestNodesConfiguration
61 
62   // Initial/ Source node of the graph. In a test multiple nodes could be launched or even a single
63   // node could be launched multiple times using [OnboardingTestRule#launch] call. Each such call
64   // would start a new graph and hence update [startNodeIdOfGraph] value.
65   private var startNodeIdOfGraph: Long = UNKNOWN_NODE_ID
66   private var hasOnboardingNodeScreenshotAnnotation: Boolean = false
67   private var hasTakenScreenshot: Boolean = false
68   private var currentTestName: String = ""
69   private var nameOfNodeToTakeScreenshot: String = ""
70   private var componentOfNodeToTakeScreenshot: String = ""
71 
72   /**
73   * Initializes the states used by this test rule.
74   *
75   * This method should <b>not</b> be called directly. It is invoked internally by the [DeviceState]
76   * rule that is part of the Bedstead project.
77   */
78   fun init(description: Description) {
79     if (description.isTest) {
80       Log.e(TAG, "Setting up OnboardingTestsRule")
81 
82       currentTestName = description.methodName
83 
84       // Extract test configuration from all the annotations applied to the test.
85       testNodesConfiguration = extractTestNodesConfiguration(description)
86       // Start Reading OnboardingEvents Logs.
87       logcatReader = LogcatReader(filterTag = ONBOARDING_EVENT_LOG_TAG).apply { start() }
88       // Create an array of [ContentValues] representing the Test Configs.
89       val contentValuesForTestConfigs =
90         createContentValuesForAllowedNodes(testNodesConfiguration.allowedNodes)
91       /* Now that we have an array of [allowedNodes] present in [contentValuesForTestConfigs],
92         store them in all the apps in [appsStoringTestConfig] using their [TestContentProvider].
93       */
94       for (appPackageName in testNodesConfiguration.appsStoringTestConfig) {
95         insertTestConfigsInApp(contentValuesForTestConfigs, appPackageName)
96       }
97     }
98   }
99 
100   /**
101   * Tears down the states used by this test rule.
102   *
103   * This method should <b>not</b> be called directly. It is invoked internally by the [DeviceState]
104   * rule that is part of the Bedstead project.
105   */
106   fun teardown() {
107     Log.e(TAG, "Tearing down OnboardingTestsRule")
108 
109     logcatReader.stopReadingLogs()
110     deleteAllTestConfigs(testNodesConfiguration.appsStoringTestConfig)
111     if (hasOnboardingNodeScreenshotAnnotation && !hasTakenScreenshot) {
112       throw AssertionError("OnboardingTestRule#takeScreenshot() not called in this test.")
113     }
114   }
115 
116   var graph: OnboardingGraphProvider? = null
117 
118   /** Provides access to the onboarding graph to be used to query for events. */
119   fun graph(): OnboardingGraphProvider {
120     if (graph == null) {
121       graph = OnboardingGraphProvider(logcatReader)
122     }
123     return graph!!
124   }
125 
126   /** Creates a fake activity node from given node contract [nodeToFake]. */
127   fun <I, O> fake(
128     nodeToFake: KClass<out OnboardingActivityApiContract<I, O>>
129   ): FakeActivityNode<I, O> {
130     val appsStoringFakeNodeConfig = testNodesConfiguration.appsStoringTestConfig.toMutableSet()
131     appsStoringFakeNodeConfig.add(extractNodeDataAndItsAppPackage(nodeToFake).second)
132     return FakeActivityNode(
133       appsStoringFakeNodeConfig = appsStoringFakeNodeConfig.toSet(),
134       activityNode = nodeToFake,
135       context = instrumentationContext,
136     )
137   }
138 
139   /**
140    * Launches an activity using [OnboardingActivityApiContract]. It will wait indefinitely until the
141    * activity starts. Each call to this method will start a new graph.
142    */
143   fun <I, O> launchAndWaitForNodeToStart(
144     activityContract: OnboardingActivityApiContract<I, O>,
145     args: I,
146   ) {
147     ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
148       instrumentationContext,
149       activityContract::class.java,
150     )
151     val nodeIntent = createNodeIntent(activityContract, args)
152     startTrampolineActivity(activityContract, nodeIntent)
153   }
154 
155   /**
156    * Launches an activity using [ArgumentFreeOnboardingActivityApiContract]. It will wait
157    * indefinitely until the activity starts. Each call to this method will start a new graph.
158    */
159   fun <O> launchAndWaitForNodeToStart(
160     activityContract: ArgumentFreeOnboardingActivityApiContract<O>
161   ) {
162     ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
163       instrumentationContext,
164       activityContract::class.java,
165     )
166     val nodeIntent = createNodeIntent(activityContract, Unit)
167     startTrampolineActivity(activityContract, nodeIntent)
168   }
169 
170   /**
171    * Launches an activity which do not return result using [VoidOnboardingActivityApiContract] and
172    * contract arguments. It will wait indefinitely until the activity starts. Each call to this
173    * method will start a new graph.
174    */
175   fun <I> launchAndWaitForNodeToStart(
176     activityContract: VoidOnboardingActivityApiContract<I>,
177     args: I,
178   ) {
179     ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
180       instrumentationContext,
181       activityContract::class.java,
182     )
183     val nodeIntent = createNodeIntent(activityContract, args)
184     startTrampolineActivity(activityContract, nodeIntent)
185   }
186 
187   /**
188    * Launches an activity which do not return result using
189    * [ArgumentFreeVoidOnboardingActivityApiContract]. It will wait indefinitely until the activity
190    * starts. Each call to this method will start a new graph.
191    */
192   fun launchAndWaitForNodeToStart(activityContract: ArgumentFreeVoidOnboardingActivityApiContract) {
193     ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
194       instrumentationContext,
195       activityContract::class.java,
196     )
197     val nodeIntent = createNodeIntent(activityContract, Unit)
198     startTrampolineActivity(activityContract, nodeIntent)
199   }
200 
201   /** Captures the current UI on the device and saves it to the test output file. */
202   fun takeScreenshot() {
203     Log.i(TAG, "Taking screenshot of node $nameOfNodeToTakeScreenshot")
204     hasTakenScreenshot = true
205 
206     val screenshot = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
207     val outputStream = testStorage.openOutputFile(/* pathname= */ getScreenshotName())
208     // Convert the bitmap to PNG.
209     screenshot.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outputStream)
210 
211     // Adding node info to the metadata of the generated sponge.
212     testStorage.addOutputProperties(
213       mapOf(
214         "onboarding_screenshot_node" to nameOfNodeToTakeScreenshot,
215         "onboarding_screenshot_component" to componentOfNodeToTakeScreenshot,
216       )
217     )
218   }
219 
220   /**
221    * Given the [contractClass] of node, it blocks the test until the node has completed execution.
222    * Currently it supports only activity nodes. Other node types will be handled later. There are 3
223    * scenarios for node completion for activities.
224    * 1. For nodes which return result, until that node has returned result i.e.,
225    *    [ActivityNodeSetResult] event has been logged.
226    * 2. Some other node has been started. This is a proxy for representing that the node of interest
227    *    has finished execution.
228    * 3. User-initiated exits like lifecycle events representing destroy, backpress, etc.
229    */
230   fun blockUntilNodeHasFinished(contractClass: KClass<*>) {
231     // Find contract identifier of the node of interest given its contract class.
232     val contractIdentifier = ContractUtils.getContractIdentifier(contractClass.java)
233     while (true) {
234       val graph = OnboardingGraph(getOnboardingEvents())
235       if (hasNodeFinishedExecution(contractIdentifier, graph)) return
236       // In the logcat, we have not received OnboardingEvent for given node, so wait for some
237       // time before checking again.
238       Thread.sleep(1000)
239     }
240   }
241 
242   /**
243    * Given the [contractIdentifierOfNodeOfInterest] it queries the given [graph] to check if this
244    * node has finished execution.
245    */
246   internal fun hasNodeFinishedExecution(
247     contractIdentifierOfNodeOfInterest: String,
248     graph: OnboardingGraph,
249   ): Boolean {
250     // Find the [OnboardingGraph.Node] entry for the source node of graph.
251     val startNodes = graph.nodes.entries.filter { it.key == startNodeIdOfGraph }
252     if (startNodes.isEmpty()) return false
253     val startNode = startNodes.first()
254 
255     val nodeIdOfInterest =
256       findEarliestStartedNodeWithGivenContract(
257         startNode.key,
258         graph,
259         contractIdentifierOfNodeOfInterest,
260       ) ?: return false
261     val nodeToWaitFor = graph.nodes[nodeIdOfInterest]!!
262 
263     val events = nodeToWaitFor.events
264     // Unblock, once the node completion condition has reached.
265     return events.any {
266       it.source is OnboardingEvent.ActivityNodeFinished ||
267         isAnotherNodeAttemptedToBeExecutedForResult(it.source, contractIdentifierOfNodeOfInterest)
268     } || nodeToWaitFor.outgoingEdgesOfValidNodes.isNotEmpty()
269   }
270 
271   /**
272    * This will do a bfs traversal of the given [graph] whose initial node is [startNodeId]. It will
273    * look for all nodes whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. Out of
274    * those it will return the one which was started first. If there is no node with
275    * contractIdentifier as [contractIdentifierOfNodeOfInterest] it will return null.
276    */
277   internal fun findEarliestStartedNodeWithGivenContract(
278     startNodeId: Long,
279     graph: OnboardingGraph,
280     contractIdentifierOfNodeOfInterest: String,
281   ): Long? {
282     val queue = ArrayDeque<Long>().apply { add(startNodeId) } // Add initial node
283     val visitedNodes = mutableSetOf<Long>()
284     var minStartTimeOfNodeOfInterest: Instant = Instant.MAX
285     var probableNodeOfInterest: Long? = null
286 
287     while (queue.isNotEmpty()) {
288       val currentNode = graph.nodes[queue.removeFirst()]!!
289       visitedNodes.add(currentNode.id)
290       if (
291         ContractUtils.getContractIdentifierForNode(currentNode) ==
292           contractIdentifierOfNodeOfInterest && currentNode.start < minStartTimeOfNodeOfInterest
293       ) {
294         probableNodeOfInterest = currentNode.id
295         minStartTimeOfNodeOfInterest = currentNode.start
296       }
297 
298       for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
299         if (!visitedNodes.contains(outgoingNode.node.id)) {
300           queue.add(outgoingNode.node.id)
301         }
302       }
303     }
304     return probableNodeOfInterest
305   }
306 
307   internal fun getStartNodeIdOfGraph() = startNodeIdOfGraph
308 
309   // Internal helper function to be used only in robolectric tests.
310   internal fun setStartNodeIdOfGraph(nodeId: Long) {
311     if (requireRobolectric()) {
312       startNodeIdOfGraph = nodeId
313     } else {
314       throw IllegalAccessException("Not allowed to access internal methods")
315     }
316   }
317 
318   /**
319    * Creates an [Intent] to launch the node for contract [activityContract] using [contractArgs].
320    */
321   internal fun <I> createNodeIntent(
322     activityContract: OnboardingActivityApiContract<I, *>,
323     contractArgs: I,
324   ): Intent {
325     // Use reflection to invoke the [OnboardingActivityApiContract#performCreateIntent()] of
326     // [activityContract] to create [Intent]. We assume that the instrumented app is not proguarded.
327     val intentCreationMethod =
328       activityContract::class.java.methods.firstOrNull {
329         it.name == INTENT_CREATION_METHOD_NAME && it.parameterCount == 2
330       }
331 
332     if (intentCreationMethod != null) {
333       intentCreationMethod.isAccessible = true
334       val nodeIntent =
335         intentCreationMethod.invoke(activityContract, instrumentationContext, contractArgs)
336           as? Intent ?: error("Unable to create valid Intent for contract $activityContract")
337       // Since we start the node for [activityContract] using startActivity from
338       // [TrampolineActivity], so the graph is broken, i.e. we can't know what is the nodeId of the
339       // [activityContract]. So as a workaround we set the nodeId in the [Intent] here itself.
340       startNodeIdOfGraph = nodeIntent.nodeId
341       Log.i(TAG, "Activity will be launched with nodeId $startNodeIdOfGraph")
342       nodeIntent.putExtra(EXTRA_ONBOARDING_NODE_ID, startNodeIdOfGraph)
343       return nodeIntent
344     } else {
345       throw IllegalStateException(
346         "Couldn't find $INTENT_CREATION_METHOD_NAME method for contract $activityContract"
347       )
348     }
349   }
350 
351   /**
352    * Given the [contractIdentifierOfNodeOfInterest] of the activity node, find if it has started by
353    * querying the onboarding events [graph] for [ActivityNodeResumed] event. If the
354    * [ActivityNodeResumed] event is logged for the node then it means that the activity has started
355    * and is visible.
356    */
357   internal fun hasActivityNodeStarted(
358     contractIdentifierOfNodeOfInterest: String,
359     graph: OnboardingGraph,
360   ): Boolean {
361     // Find the [OnboardingGraph.Node] entry for the source node of graph.
362     val startNode = graph.nodes.entries.find { it.key == startNodeIdOfGraph } ?: return false
363     return hasEventOccurred(
364       OnboardingEvent.ActivityNodeResumed::class,
365       contractIdentifierOfNodeOfInterest,
366       startNode.key,
367       graph,
368     )
369   }
370 
371   /**
372    * Returns if the event represented by its class [eventClassOfInterest] has occurred for the node
373    * whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. This will do a bfs traversal
374    * of the given [graph] whose initial node is [startNodeId].
375    */
376   internal fun hasEventOccurred(
377     eventClassOfInterest: KClass<*>,
378     contractIdentifierOfNodeOfInterest: String,
379     startNodeId: Long,
380     graph: OnboardingGraph,
381   ): Boolean {
382     val queue = ArrayDeque<Long>().apply { add(startNodeId) } // Add initial node
383     val visitedNodes = mutableSetOf<Long>()
384     while (queue.isNotEmpty()) {
385       val currentNode = graph.nodes.getValue(queue.removeFirst())
386       visitedNodes.add(currentNode.id)
387       if (
388         ContractUtils.getContractIdentifierForNode(currentNode) ==
389           contractIdentifierOfNodeOfInterest &&
390           currentNode.spawnedEvents.any { it.source::class == eventClassOfInterest }
391       ) {
392         return true
393       }
394 
395       for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
396         if (!visitedNodes.contains(outgoingNode.node.id)) {
397           queue.add(outgoingNode.node.id)
398         }
399       }
400     }
401     return false
402   }
403 
404   /**
405    * Checks if the activity launched has been validated using the correct contract with which it was
406    * launched. If not it will throw an error. Note that it will also throw an error if the activity
407    * was validated twice once using correct and another time using incorrect correct.
408    */
409   internal fun throwErrorIfActivityNotValidatedUsingCorrectContract(
410     onboardingEvents: Set<OnboardingEvent>,
411     contractIdentifierOfInterest: String,
412   ) {
413     val invalidEvent =
414       onboardingEvents.find {
415         isInvalidActivityNodeValidatingEvent(it, contractIdentifierOfInterest, startNodeIdOfGraph)
416       }
417     if (invalidEvent != null) {
418       error(
419         "Please use the correct contract to validate the launched activity. Invalid event is $invalidEvent"
420       )
421     }
422   }
423 
424   private fun isInvalidActivityNodeValidatingEvent(
425     event: OnboardingEvent,
426     expectedContractIdentifier: String,
427     activityNodeId: Long,
428   ) =
429     event.nodeId == activityNodeId &&
430       event is OnboardingEvent.ActivityNodeValidating &&
431       ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
432         expectedContractIdentifier
433 
434   /** Returns if the calling function is executed on robolectric. */
435   private fun requireRobolectric() = "robolectric" == Build.FINGERPRINT
436 
437   /**
438    * Stores the test configs using the ContentProvider of the app with given [appPackageName].
439    *
440    * @param contentValues An array of ContentValues representing the test configurations.
441    * @param appPackageName package name of the app where test configs would be stored.
442    */
443   private fun insertTestConfigsInApp(contentValues: Array<ContentValues>, appPackageName: String) {
444     /* Delete test configurations stored by app before inserting new ones. */
445     deleteTestConfigsOfApp(appPackageName)
446     val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(appPackageName))
447     instrumentationContext.contentResolver.bulkInsert(uri, contentValues)
448   }
449 
450   /** Returns the immutable set of OnboardingEvents read from logs */
451   private fun getOnboardingEvents(): Set<OnboardingEvent> {
452     val onboardingEvents = mutableSetOf<OnboardingEvent>()
453     for (logLines in logcatReader.getFilteredLogs()) {
454       // Is an onboarding graph line containing OnboardingEvent.
455       val encoded = logLines.split("${ONBOARDING_EVENT_LOG_TAG}: ")[1]
456 
457       // Deserialize and store the OnboardingEvent in-memory.
458       val event = OnboardingEvent.deserialize(encoded)
459       onboardingEvents.add(event)
460     }
461     return onboardingEvents.toSet()
462   }
463 
464   /**
465    * Returns [true] if a node other than the one with name [contractIdentifierOfNode], has been
466    * started for result
467    */
468   private fun isAnotherNodeAttemptedToBeExecutedForResult(
469     event: OnboardingEvent,
470     contractIdentifierOfNode: String,
471   ) =
472     (event is OnboardingEvent.ActivityNodeStartExecuteSynchronously) &&
473       (ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
474         contractIdentifierOfNode)
475 
476   /**
477    * Start the [TrampolineActivity] in the app owning [activityContract]. The [Intent] for
478    * [TrampolineActivity] will also contain the [nodeIntent] to launch the actual intended node for
479    * [activityContract] as an extra.
480    */
481   private fun startTrampolineActivity(
482     activityContract: OnboardingActivityApiContract<*, *>,
483     nodeIntent: Intent,
484   ) {
485     val trampolineActivityIntent =
486       Intent(getTrampolineActivityIntentAction(activityContract)).apply {
487         putExtra(EXTRA_NODE_START_INTENT_KEY, nodeIntent)
488         flags = INTENT_FLAGS_START_IN_NEW_TASK
489       }
490 
491     val activityOptions =
492       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
493         ActivityOptions.makeBasic().apply { isShareIdentityEnabled = true }
494       } else {
495         null
496       }
497 
498     // Start activity and wait.
499     instrumentationContext.startActivity(trampolineActivityIntent, activityOptions?.toBundle())
500     waitForActivityLaunch(activityContract)
501   }
502 
503   /** Get the [Intent] action to launch Trampoline Activity of the application owning [contract]. */
504   private fun getTrampolineActivityIntentAction(
505     contract: OnboardingActivityApiContract<*, *>
506   ): String {
507     val onboardingNode =
508       extractOnboardingNodeAnnotation(contract)
509         ?: error("Can't fetch OnboardingNode annotation for contract $contract")
510     val onboardingNodeMetadata = getOnboardingNodeMetadata(onboardingNode)
511 
512     return "${onboardingNodeMetadata.packageName}${TRAMPOLINE_ACTIVITY_NAME}"
513   }
514 
515   /**
516    * Extract the [OnboardingNode] annotation applied to activity [contract]. There should be exactly
517    * 1 such annotation and if there is none then null is returned.
518    */
519   private fun extractOnboardingNodeAnnotation(contract: OnboardingActivityApiContract<*, *>) =
520     contract::class.annotations.filterIsInstance<OnboardingNode>().firstOrNull()
521 
522   /**
523    * From the [TestNodes] annotation, extract all the nodes which are allowed to execute and also
524    * the package name of the apps containing the nodes and store them in [TestNodesConfiguration].
525    */
526   private fun processTestNodesAnnotation(testNodes: TestNodes): TestNodesConfiguration {
527     val allowedNodes = mutableListOf<NodeData>()
528     val appsStoringTestConfig = mutableSetOf<String>(instrumentationContext.packageName)
529 
530     for (node in testNodes.nodes) {
531       extractNodeDataAndItsAppPackage(node.contract).apply {
532         allowedNodes.add(first)
533         appsStoringTestConfig.add(second)
534       }
535     }
536     return TestNodesConfiguration(appsStoringTestConfig.toSet(), allowedNodes.toList())
537   }
538 
539   /**
540    * From the [OnboardingNodeScreenshot] annotation, extract the allowed node to execute and also
541    * the package name of the app containing this node and store them in [TestNodesConfiguration].
542    */
543   private fun processOnboardingNodeScreenshotAnnotation(
544     onboardingNodeScreenshot: OnboardingNodeScreenshot
545   ): TestNodesConfiguration {
546     validateUiNode(onboardingNodeScreenshot.node.contract.annotations)
547     hasOnboardingNodeScreenshotAnnotation = true
548 
549     var allowedNode: NodeData
550     val appsStoringTestConfig = mutableSetOf(instrumentationContext.packageName)
551 
552     extractNodeDataAndItsAppPackage(onboardingNodeScreenshot.node.contract).apply {
553       allowedNode = first
554       appsStoringTestConfig.add(second)
555     }
556 
557     return TestNodesConfiguration(appsStoringTestConfig.toSet(), listOf(allowedNode))
558   }
559 
560   private fun validateUiNode(annotations: List<Annotation>) {
561     for (annotation in annotations) {
562       if (annotation is OnboardingNode) {
563         when (annotation.uiType) {
564           OnboardingNode.UiType.EDUCATION,
565           OnboardingNode.UiType.INPUT,
566           OnboardingNode.UiType.LOADING,
567           OnboardingNode.UiType.ERROR,
568           OnboardingNode.UiType.OTHER -> {
569             nameOfNodeToTakeScreenshot = annotation.name
570             componentOfNodeToTakeScreenshot = annotation.component
571           }
572           OnboardingNode.UiType.HOST,
573           OnboardingNode.UiType.NONE,
574           OnboardingNode.UiType.INVISIBLE ->
575             throw IllegalArgumentException(
576               "Not allowed to take screenshot for non-UI onboarding nodes."
577             )
578         }
579       }
580     }
581   }
582 
583   /**
584    * Given a node's contract class, returns its [NodeData] representation and the package name of
585    * the app to which it belongs.
586    */
587   internal fun extractNodeDataAndItsAppPackage(nodeContract: KClass<*>): Pair<NodeData, String> {
588     for (annotation in nodeContract.annotations) {
589       if (annotation is OnboardingNode) {
590         val onboardingNodeMetadata = getOnboardingNodeMetadata(annotation)
591         return Pair(
592           NodeData(
593             allowedContractIdentifier = ContractUtils.getContractIdentifier(nodeContract.java)
594           ),
595           onboardingNodeMetadata.packageName,
596         )
597       }
598     }
599     error("$nodeContract class does not have OnboardingNode annotation")
600   }
601 
602   private fun getOnboardingNodeMetadata(onboardingNode: OnboardingNode): OnboardingNodeMetadata {
603     val nodeMetadata = onboardingNode.component.split("/")
604     return when (nodeMetadata.size) {
605       1 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[0])
606       2 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[1])
607       else -> error("OnboardingNode component ${onboardingNode.component} is invalid")
608     }
609   }
610 
611   // Creates a list of ContentValues representing the list of allowedNodes.
612   private fun createContentValuesForAllowedNodes(
613     allowedNodes: List<NodeData>
614   ): Array<ContentValues> =
615     allowedNodes
616       .map { node ->
617         ContentValues().apply { put(TEST_NODE_CLASS_COLUMN, node.allowedContractIdentifier) }
618       }
619       .toTypedArray()
620 
621   // Deletes the test configs stored all the apps listed in [appsStoringTestConfig] .
622   private fun deleteAllTestConfigs(appsStoringTestConfig: Set<String>) {
623     for (appPackageName in appsStoringTestConfig) {
624       deleteTestConfigsOfApp(appPackageName)
625     }
626   }
627 
628   // Deletes the test configs stored by a given app.
629   private fun deleteTestConfigsOfApp(packageName: String) {
630     val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(packageName))
631     instrumentationContext.contentResolver.delete(uri, /* where= */ null, /* selectionArgs= */ null)
632   }
633 
634   /**
635    * Extracts the [TestNodesConfiguration] from all annotations applied to [description].
636    *
637    * If no annotation is provided, then null will be returned.
638    */
639   private fun extractTestNodesConfiguration(description: Description): TestNodesConfiguration {
640     val testNodesAnnotationConfiguration =
641       extractTestNodesAnnotation(description)?.let { processTestNodesAnnotation(it) }
642         ?: TestNodesConfiguration()
643     val onboardingNodeScreenshotAnnotationTestConfiguration =
644       extractOnboardingNodeScreenshotAnnotation(description)?.let {
645         processOnboardingNodeScreenshotAnnotation(it)
646       } ?: TestNodesConfiguration()
647 
648     return TestNodesConfiguration(
649       appsStoringTestConfig =
650         testNodesAnnotationConfiguration.appsStoringTestConfig +
651           onboardingNodeScreenshotAnnotationTestConfiguration.appsStoringTestConfig,
652       allowedNodes =
653         testNodesAnnotationConfiguration.allowedNodes +
654           onboardingNodeScreenshotAnnotationTestConfiguration.allowedNodes,
655     )
656   }
657 
658   /**
659    * Extracts the [TestNodes] annotation applied to [description].
660    *
661    * There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
662    * returned.
663    */
664   private fun extractTestNodesAnnotation(description: Description) =
665     description.annotations?.filterIsInstance<TestNodes>()?.firstOrNull()
666 
667   /**
668    * Extracts the [OnboardingNodeScreenshot] annotation applied to [description].
669    *
670    * There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
671    * returned.
672    */
673   private fun extractOnboardingNodeScreenshotAnnotation(description: Description) =
674     description.annotations?.filterIsInstance<OnboardingNodeScreenshot>()?.firstOrNull()
675 
676   /**
677    * Returns the screenshot path derived from the component name [componentOfNodeToTakeScreenshot],
678    * node name [nameOfNodeToTakeScreenshot] and test method name [currentTestName].
679    */
680   private fun getScreenshotName() =
681     "$componentOfNodeToTakeScreenshot/$nameOfNodeToTakeScreenshot/${currentTestName}.png"
682 
683   private fun waitForActivityLaunch(activityContract: OnboardingActivityApiContract<*, *>) {
684     if (!requireRobolectric()) {
685       val contractIdentifier = ContractUtils.getContractIdentifier(activityContract::class.java)
686       val contractPackageName = extractNodeDataAndItsAppPackage(activityContract::class).second
687       while (true) {
688         val onboardingEvents = getOnboardingEvents()
689         throwErrorIfActivityNotValidatedUsingCorrectContract(onboardingEvents, contractIdentifier)
690         val graph = OnboardingGraph(onboardingEvents)
691         if (hasActivityNodeStarted(contractIdentifier, graph)) {
692           // Pause the test for up to 60 seconds, waiting for a top-level UI element (depth 0) from
693           // the package specified by [contractPackageName] to appear on the screen.
694           device.wait(
695             Until.hasObject(By.pkg(contractPackageName).depth(0)),
696             Duration.ofSeconds(60).toMillis(),
697           )
698           Thread.sleep(1000) // Wait an additional second in case UI automator is flaky.
699           return
700         }
701         Thread.sleep(100) // Check every 100 milliseconds.
702       }
703     }
704   }
705 
706   /**
707    * Stores the test configuration obtained by processing test level annotations. This data will be
708    * later used to store the list of [allowedNodes] in all the apps given by [appsStoringTestConfig]
709    * using their [TestContentProvider].
710    */
711   private data class TestNodesConfiguration(
712     /**
713      * Stores the package name of apps where the test configs will be stored. This includes the app
714      * package name of allowed nodes and the instrumented app.
715      */
716     val appsStoringTestConfig: Set<String> = setOf(),
717 
718     /** Stores the list of nodes which are allowed to execute in tests. */
719     val allowedNodes: List<NodeData> = listOf(),
720   )
721 
722   companion object {
723     const val TAG = "OnboardingTestsRule"
724     const val EXTRA_NODE_START_INTENT_KEY =
725       "com.android.onboarding.bedsteadonboarding.activities.extra.NODE_START_INTENT"
726 
727     private const val INTENT_CREATION_METHOD_NAME = "performCreateIntent"
728     private const val INTENT_FLAGS_START_IN_NEW_TASK =
729       FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK
730 
731     // LINT.IfChange(activity_action_name)
732     private const val TRAMPOLINE_ACTIVITY_NAME = ".bedstead.onboarding.trampolineactivity"
733     // LINT.ThenChange(java/com/android/onboarding/bedsteadonboarding/AndroidManifest.xml:activity_action_name)
734   }
735 }
736