blob: dedf8fbce28c816c2d9b51b17da32344ecbe1fdd [file] [log] [blame]
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<I> {
/** Provides a [Launcher] to use during launches of this entity. */
val launcher: Launcher<I>
}
/** Onboarding entities that can be launched for result. */
interface LaunchableForResult<I, O> : Launchable<I> {
override val launcher: LauncherForResult<I, O>
}
/** A launcher for onboarding entities that can be launched directly. */
abstract class Launcher<I> : 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<I, O>(private val tag: String) : Launcher<I>() {
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<O>? = 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<O>? {
// 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<I, O> =
object : ActivityResultContract<I, O>() {
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<O>? =
this@LauncherForResult.getSynchronousResult(context, input)
}
}