package com.android.onboarding.contracts import android.content.Context import android.content.Intent import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContract import com.android.onboarding.bedsteadonboarding.contractutils.ContractExecutionEligibilityChecker import com.android.onboarding.bedsteadonboarding.contractutils.ContractUtils import com.android.onboarding.nodes.AndroidOnboardingGraphLog import com.android.onboarding.nodes.NodeRef import com.android.onboarding.nodes.OnboardingEvent import java.util.UUID /** Onboarding entities that can be launched. */ interface Launchable { /** Provides a [Launcher] to use during launches of this entity. */ val launcher: Launcher } /** Onboarding entities that can be launched for result. */ interface LaunchableForResult : Launchable { override val launcher: LauncherForResult } /** A launcher for onboarding entities that can be launched directly. */ abstract class Launcher : NodeRef { /** * Optional event hook for intent preparations just after an outgoing intent, [nodeId] and * [outgoingId] are prepared. */ protected open fun onPrepareIntent(nodeId: NodeId, outgoingId: NodeId) {} /** Extract a [NodeId] from a given [context]. */ protected abstract fun extractNodeId(context: Context): NodeId /** Creates an [Intent] for this entity containing the given argument. */ protected abstract fun provideIntent(context: Context, input: I): Intent /** Create an Intent with the intention of launching the contract without expecting a result. */ internal fun createIntentDirectly(context: Context, input: I): Intent { // Injection point when we are passing control out of the current activity // without expecting a result val outgoingId = newOutgoingId() val nodeId = extractNodeId(context) val intent = provideIntent(context, input).apply { putExtra(EXTRA_ONBOARDING_NODE_ID, outgoingId) } onPrepareIntent(nodeId, outgoingId) AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeExecutedDirectly( sourceNodeId = nodeId, nodeId = outgoingId, nodeComponent = nodeComponent, nodeName = nodeName, argument = input, ) ) ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed( context = context, contractIdentifier = ContractUtils.getContractIdentifier(nodeComponent = nodeComponent, nodeName = nodeName), ) return intent } companion object { /** * Create a new ID to be used for the node started by this launchable. * * This is only used when starting a launchable - it is not used when extracting arguments * during the execution of a contract. In that case, the ID is extracted from the activity * intent. */ internal fun newOutgoingId(): Long = UUID.randomUUID().leastSignificantBits } } /** A launcher for onboarding entities that can be launched for result. */ abstract class LauncherForResult(private val tag: String) : Launcher() { protected open var forResultOutGoingId: NodeId = UNKNOWN_NODE_ID /** * Fetches the result without starting the activity. * * This can be optionally implemented, and should return null if a result cannot be fetched and * the activity should be started. */ protected open fun provideSynchronousResult( context: Context, args: I, ): ActivityResultContract.SynchronousResult? = null /** Extracts the result from the low-level representation [ActivityResult]. */ protected abstract fun provideResult(result: ActivityResult): O /** @see ActivityResultContract.createIntent */ internal fun createIntent(context: Context, input: I): Intent { // Injection point when we are passing control out of the current activity val intent = provideIntent(context, input) val nodeId = extractNodeId(context) // We should assume forResultOutGoingId is already set because, in // activityResultRegistry.onLaunch, getSynchronousResult is always called first. Then // createIntent may be called afterwards. // We should expect a outgoingId created in getSynchronousResult and store it in // forResultOutGoingId. // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/ComponentActivity.kt if (forResultOutGoingId != UNKNOWN_NODE_ID) { intent.putExtra(EXTRA_ONBOARDING_NODE_ID, forResultOutGoingId) } else { // getSynchronousResult is not called. This may be not called from // activityResultRegistry.onLaunch. We will use the outgoing which was just created in // performCreateIntent. forResultOutGoingId = intent.getLongExtra(EXTRA_ONBOARDING_NODE_ID, UNKNOWN_NODE_ID) Log.w(tag, "getSynchronousResult was not called when creating intent.") } AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeExecutedForResult( sourceNodeId = nodeId, nodeId = forResultOutGoingId, nodeComponent = nodeComponent, nodeName = nodeName, argument = input, ) ) forResultOutGoingId = UNKNOWN_NODE_ID return intent } /** @see ActivityResultContract.parseResult */ internal fun parseResult(resultCode: Int, intent: Intent?): O { // Injection point when control has returned to the current activity val id = intent?.nodeId ?: UNKNOWN_NODE_ID val result = provideResult(ActivityResult(resultCode, intent)) AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeResultReceived( nodeId = id, nodeComponent = nodeComponent, nodeName = nodeName, result = result, ) ) if (result is NodeResult.Failure) { AndroidOnboardingGraphLog.log(OnboardingEvent.ActivityNodeFail(id, result.toString())) } return result } /** @see ActivityResultContract.getSynchronousResult */ internal fun getSynchronousResult( context: Context, input: I, ): ActivityResultContract.SynchronousResult? { // Injection point when making a synchronous call val contractIdentifier = ContractUtils.getContractIdentifier(nodeComponent, nodeName) ContractUtils.getContractResultIfNodeIsFakedInTest(context, contractIdentifier)?.let { result -> Log.i(tag, "Contract result fetched for fake node $contractIdentifier is $result") return ActivityResultContract.SynchronousResult(provideResult(result.toActivityResult())) } val thisNodeId = extractNodeId(context) forResultOutGoingId = newOutgoingId() AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeStartExecuteSynchronously( sourceNodeId = thisNodeId, nodeId = forResultOutGoingId, nodeComponent = nodeComponent, nodeName = nodeName, argument = input, ) ) ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed( context = context, contractIdentifier = ContractUtils.getContractIdentifier(nodeComponent = nodeComponent, nodeName = nodeName), ) val result = provideSynchronousResult(context, input) if (result != null) { // Injection point when the synchronous result was used and the activity was skipped AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeExecutedSynchronously( nodeId = forResultOutGoingId, nodeComponent = nodeComponent, nodeName = nodeName, result = result.value, ) ) } return result } /** Build an [ActivityResultContract] wrapper that delegates to this [LauncherForResult]. */ open fun toActivityResultContract(): ActivityResultContract = object : ActivityResultContract() { override fun createIntent(context: Context, input: I): Intent = this@LauncherForResult.createIntent(context, input) override fun parseResult(resultCode: Int, intent: Intent?): O = this@LauncherForResult.parseResult(resultCode, intent) override fun getSynchronousResult(context: Context, input: I): SynchronousResult? = this@LauncherForResult.getSynchronousResult(context, input) } }