package com.android.onboarding.contracts import android.app.Activity import android.content.Context import android.content.Intent import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContract import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import com.android.onboarding.contracts.annotations.InternalOnboardingApi import com.android.onboarding.contracts.annotations.OnboardingNode import com.android.onboarding.nodes.AndroidOnboardingGraphLog import com.android.onboarding.nodes.NodeRef import com.android.onboarding.nodes.OnboardingEvent import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeArgumentExtracted import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeExtractArgument import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeFail import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeFailedValidation import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeSetResult import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeValidating import com.google.errorprone.annotations.CanIgnoreReturnValue /** * A Contract used for launching an activity as part of the Onboarding flow. * *

It is required that all Activity starts in the Android Onboarding flow go via contracts. This * is to allow for better central tracking of onboarding sessions. */ abstract class OnboardingActivityApiContract : ActivityResultContract(), LaunchableForResult, NodeRef { /** * Extracted [OnboardingNode] metadata for this contract. * * Resolved lazily and throws an error if the annotation is not present. */ @InternalOnboardingApi val metadata: OnboardingNode by lazy { this::class.java.getAnnotation(OnboardingNode::class.java) ?: error("${this::class.qualifiedName} is missing OnboardingNode annotation") } @OptIn(InternalOnboardingApi::class) final override val nodeComponent: String by lazy(metadata::component) @OptIn(InternalOnboardingApi::class) final override val nodeName by lazy(metadata::name) override val launcher = object : LauncherForResult(TAG) { override val nodeComponent: String get() = this@OnboardingActivityApiContract.nodeComponent override val nodeName: String get() = this@OnboardingActivityApiContract.nodeName override fun provideResult(result: ActivityResult): O = performParseResult(result) override fun provideSynchronousResult(context: Context, args: I): SynchronousResult? = performGetSynchronousResult(context, args) override fun extractNodeId(context: Context): NodeId = this@OnboardingActivityApiContract.extractNodeId(context) override fun provideIntent(context: Context, input: I): Intent = performCreateIntent(context, input) override fun toActivityResultContract() = this@OnboardingActivityApiContract override fun onPrepareIntent(nodeId: NodeId, outgoingId: NodeId) { // Track for resume event for activity node id. waitingForResumeActivity[nodeId] = outgoingId } } /* * This is true in all known cases - but we need to accept Context because that's the * AndroidX * API surface */ private fun extractNodeId(context: Context): NodeId = context.activityNodeId() /** Creates an {@link Intent} for this contract containing the given argument. */ final override fun createIntent(context: Context, input: I): Intent = launcher.createIntent(context, input) final override fun parseResult(resultCode: Int, intent: Intent?): O = launcher.parseResult(resultCode, intent) final override fun getSynchronousResult(context: Context, input: I): SynchronousResult? = launcher.getSynchronousResult(context, input) /** * Creates an [Intent] for this contract containing the given argument. * * This should be symmetric with [performExtractArgument]. */ protected abstract fun performCreateIntent(context: Context, arg: I): Intent /** * Extracts the argument from the given [Intent]. * *

This should be symmetric with [performCreateIntent]. */ protected abstract fun performExtractArgument(intent: Intent): I /** * Convert the given result into the low-level representation [ActivityResult]. * * This should be symmetric with [performParseResult]. */ protected abstract fun performSetResult(result: O): ActivityResult /** * Extracts the result from the low-level representation [ActivityResult]. * * This should be symmetric with [performSetResult]. */ protected abstract fun performParseResult(result: ActivityResult): O /** * 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 performGetSynchronousResult(context: Context, args: I): SynchronousResult? = null /** Extracts an argument passed into the current activity using the contract. */ fun extractArgument(intent: Intent): I { // Injection point when we are receiving control in an activity AndroidOnboardingGraphLog.log( ActivityNodeExtractArgument(intent.nodeId, this.javaClass, intentToIntentData(intent)) ) val argument = performExtractArgument(intent) AndroidOnboardingGraphLog.log( ActivityNodeArgumentExtracted(intent.nodeId, this.javaClass, argument) ) return argument } private fun intentToIntentData(intent: Intent): OnboardingEvent.IntentData { val extras = buildMap { intent.extras?.let { for (key in it.keySet()) put(key, it.get(key)) } } return OnboardingEvent.IntentData(intent.action, extras) } /** Sets a result for this contract. */ fun setResult(activity: Activity, result: O) { // Injection point when we are returning a result from the current activity AndroidOnboardingGraphLog.log(ActivityNodeSetResult(activity.nodeId, this.javaClass, result)) if (result is NodeResult.Failure) { AndroidOnboardingGraphLog.log(ActivityNodeFail(activity.nodeId, result.toString())) } val activityResult = performSetResult(result) val intent = activityResult.data ?: Intent() intent.putExtra(EXTRA_ONBOARDING_NODE_ID, activity.nodeId) activity.setResult(activityResult.resultCode, intent) } class OnboardingLifecycleObserver( private var activity: Activity?, private val contract: OnboardingActivityApiContract, ) : LifecycleEventObserver { private var isFinishLogged = false private fun maybeLogFinish() { if (!isFinishLogged && activity?.isFinishing == true) { isFinishLogged = true AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeFinished( activity?.nodeId ?: UNKNOWN_NODE_ID, contract.javaClass, ) ) } } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_PAUSE -> maybeLogFinish() Lifecycle.Event.ON_DESTROY -> { maybeLogFinish() activity?.nodeId?.let { waitingForResumeActivity.remove(it) } // Clear the activity reference to avoid memory leak. activity = null } Lifecycle.Event.ON_RESUME -> { val nodeId = activity?.nodeId ?: UNKNOWN_NODE_ID if (nodeId == UNKNOWN_NODE_ID) { Log.w(TAG, "${activity?.componentName} does not contain node id.") } AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeResumed(nodeId, contract.javaClass) ) val waitingForResume = nodeId != UNKNOWN_NODE_ID && waitingForResumeActivity.contains(nodeId) if (waitingForResume) { val sourceNodeId = waitingForResumeActivity[nodeId] ?: UNKNOWN_NODE_ID AndroidOnboardingGraphLog.log( OnboardingEvent.ActivityNodeResumedAfterLaunch( sourceNodeId, nodeId, contract.javaClass, ) ) waitingForResumeActivity.remove(nodeId) } } Lifecycle.Event.ON_CREATE, Lifecycle.Event.ON_START, Lifecycle.Event.ON_ANY -> {} } } } /** * Attaches to the specified Activity in onCreate. This validates the intent can be parsed into an * argument. * * @param activity The Activity to attach to. * @param intent The Intent to use (defaults to the Activity's intent). * @return An AttachedResult object with information about the attachment, including the * validation result. */ @CanIgnoreReturnValue fun attach(activity: ComponentActivity, intent: Intent = activity.intent): AttachedResult { return attach(activity, activity.lifecycle, intent) } /** * Attaches to the specified Activity in onCreate. This validates the intent can be parsed into an * argument. * * A lifecycle should be provided. If the activity does not support lifecycle owner, it should * implement a LifeCycleOwner or migrate to use androidx ComponentActivity. * * @param activity The Activity to attach to. * @param lifecycle The Lifecycle of the activity. * @param intent The Intent to use (defaults to the Activity's intent). * @return An AttachedResult object with information about the attachment, including the * validation result. */ @CanIgnoreReturnValue fun attach( activity: Activity, lifecycle: Lifecycle?, intent: Intent = activity.intent, ): AttachedResult { val validated = validateInternal(activity, intent) lifecycle?.addObserver(OnboardingLifecycleObserver(activity, this)) return AttachedResult(validated) } /** * Validate that the intent can be parsed into an argument. * *

When parsing fails, the failure will be recorded so that it can be fixed. * * @param activity the current activity context * @param intent the [Intent] to validate (defaults to the activity's current intent) * @return `true` if the intent is valid, `false` otherwise * @deprecated use [attach]. */ @CanIgnoreReturnValue @Deprecated("Use attach instead", ReplaceWith("attach(activity, intent)")) fun validate(activity: ComponentActivity, intent: Intent = activity.intent): Boolean { return validate(activity, activity.lifecycle, intent) } /** * Validate that the intent can be parsed into an argument. * *

When parsing fails, the failure will be recorded so that it can be fixed. * * @param activity the current activity context * @param lifecycle the lifecycle of the activity, used for logging purposes * @param intent the [Intent] to validate (defaults to the activity's current intent) * @return `true` if the intent is valid, `false` otherwise */ @CanIgnoreReturnValue @Deprecated("Use attach instead", ReplaceWith("attach(activity, lifecycle, intent)")) fun validate( activity: Activity, lifecycle: Lifecycle?, intent: Intent = activity.intent, ): Boolean { val validated = validateInternal(activity, intent) lifecycle?.addObserver(OnboardingLifecycleObserver(activity, this)) return validated } private fun validateInternal(activity: Activity, intent: Intent): Boolean { AndroidOnboardingGraphLog.log( ActivityNodeValidating(activity.nodeId, this.javaClass, intentToIntentData(intent)) ) return runCatching { extractArgument(intent) } .onFailure { AndroidOnboardingGraphLog.log( ActivityNodeFailedValidation( nodeId = activity.nodeId, nodeClass = this.javaClass, exception = it, intent = intentToIntentData(intent), ) ) } .map { true } .getOrDefault(false) } companion object { @Deprecated( message = "Moved", replaceWith = ReplaceWith( "UNKNOWN_NODE_ID", imports = ["com.android.onboarding.contracts.UNKNOWN_NODE_ID"], ), ) const val UNKNOWN_NODE: NodeId = UNKNOWN_NODE_ID const val TAG = "OnboardingApiContract" // nodeId to outgoingId private val waitingForResumeActivity: MutableMap = mutableMapOf() } } /** Equivalent to [OnboardingActivityApiContract] for contracts which do not return a result. */ abstract class VoidOnboardingActivityApiContract : OnboardingActivityApiContract() { final override fun performSetResult(result: Unit): ActivityResult { // Does nothing - no result return ActivityResult(resultCode = 0, data = null) } final override fun performParseResult(result: ActivityResult) { // Does nothing - no result } } /** Equivalent to [OnboardingActivityApiContract] for contracts which do not take arguments. */ abstract class ArgumentFreeOnboardingActivityApiContract : OnboardingActivityApiContract() { final override fun performExtractArgument(intent: Intent) { // Does nothing - no argument } } /** Equivalent to [VoidOnboardingActivityApiContract] for contracts which do not take arguments. */ abstract class ArgumentFreeVoidOnboardingActivityApiContract : VoidOnboardingActivityApiContract() { final override fun performExtractArgument(intent: Intent) { // Does nothing - no argument } final override fun performCreateIntent(context: Context, arg: Unit) = performCreateIntent(context) abstract fun performCreateIntent(context: Context): Intent } /** Returns [true] if the activity is launched using onboarding contract, [false] otherwise. */ fun Activity.isLaunchedByOnboardingContract(): Boolean { return intent.hasExtra(EXTRA_ONBOARDING_NODE_ID) }