1 package com.android.onboarding.contracts
2 
3 import android.app.Activity
4 import android.content.Context
5 import android.content.Intent
6 import android.util.Log
7 import androidx.activity.ComponentActivity
8 import androidx.activity.result.ActivityResult
9 import androidx.activity.result.contract.ActivityResultContract
10 import androidx.lifecycle.Lifecycle
11 import androidx.lifecycle.LifecycleEventObserver
12 import androidx.lifecycle.LifecycleOwner
13 import com.android.onboarding.contracts.annotations.InternalOnboardingApi
14 import com.android.onboarding.contracts.annotations.OnboardingNode
15 import com.android.onboarding.nodes.AndroidOnboardingGraphLog
16 import com.android.onboarding.nodes.NodeRef
17 import com.android.onboarding.nodes.OnboardingEvent
18 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeArgumentExtracted
19 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeExtractArgument
20 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeFail
21 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeFailedValidation
22 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeSetResult
23 import com.android.onboarding.nodes.OnboardingEvent.ActivityNodeValidating
24 import com.google.errorprone.annotations.CanIgnoreReturnValue
25 
26 /**
27  * A Contract used for launching an activity as part of the Onboarding flow.
28  *
29  * <p>It is required that all Activity starts in the Android Onboarding flow go via contracts. This
30  * is to allow for better central tracking of onboarding sessions.
31  */
32 abstract class OnboardingActivityApiContract<I, O> :
33   ActivityResultContract<I, O>(), LaunchableForResult<I, O>, NodeRef {
34   /**
35    * Extracted [OnboardingNode] metadata for this contract.
36    *
37    * Resolved lazily and throws an error if the annotation is not present.
38    */
39   @InternalOnboardingApi
<lambda>null40   val metadata: OnboardingNode by lazy {
41     this::class.java.getAnnotation(OnboardingNode::class.java)
42       ?: error("${this::class.qualifiedName} is missing OnboardingNode annotation")
43   }
44 
45   @OptIn(InternalOnboardingApi::class)
46   final override val nodeComponent: String by lazy(metadata::component)
47 
48   @OptIn(InternalOnboardingApi::class) final override val nodeName by lazy(metadata::name)
49 
50   override val launcher =
51     object : LauncherForResult<I, O>(TAG) {
52       override val nodeComponent: String
53         get() = this@OnboardingActivityApiContract.nodeComponent
54 
55       override val nodeName: String
56         get() = this@OnboardingActivityApiContract.nodeName
57 
provideResultnull58       override fun provideResult(result: ActivityResult): O = performParseResult(result)
59 
60       override fun provideSynchronousResult(context: Context, args: I): SynchronousResult<O>? =
61         performGetSynchronousResult(context, args)
62 
63       override fun extractNodeId(context: Context): NodeId =
64         this@OnboardingActivityApiContract.extractNodeId(context)
65 
66       override fun provideIntent(context: Context, input: I): Intent =
67         performCreateIntent(context, input)
68 
69       override fun toActivityResultContract() = this@OnboardingActivityApiContract
70 
71       override fun onPrepareIntent(nodeId: NodeId, outgoingId: NodeId) {
72         // Track for resume event for activity node id.
73         waitingForResumeActivity[nodeId] = outgoingId
74       }
75     }
76 
77   /*
78    * This is true in all known cases - but we need to accept Context because that's the
79    * AndroidX
80    * API surface
81    */
extractNodeIdnull82   private fun extractNodeId(context: Context): NodeId = context.activityNodeId()
83 
84   /** Creates an {@link Intent} for this contract containing the given argument. */
85   final override fun createIntent(context: Context, input: I): Intent =
86     launcher.createIntent(context, input)
87 
88   final override fun parseResult(resultCode: Int, intent: Intent?): O =
89     launcher.parseResult(resultCode, intent)
90 
91   final override fun getSynchronousResult(context: Context, input: I): SynchronousResult<O>? =
92     launcher.getSynchronousResult(context, input)
93 
94   /**
95    * Creates an [Intent] for this contract containing the given argument.
96    *
97    * This should be symmetric with [performExtractArgument].
98    */
99   protected abstract fun performCreateIntent(context: Context, arg: I): Intent
100 
101   /**
102    * Extracts the argument from the given [Intent].
103    *
104    * <p>This should be symmetric with [performCreateIntent].
105    */
106   protected abstract fun performExtractArgument(intent: Intent): I
107 
108   /**
109    * Convert the given result into the low-level representation [ActivityResult].
110    *
111    * This should be symmetric with [performParseResult].
112    */
113   protected abstract fun performSetResult(result: O): ActivityResult
114 
115   /**
116    * Extracts the result from the low-level representation [ActivityResult].
117    *
118    * This should be symmetric with [performSetResult].
119    */
120   protected abstract fun performParseResult(result: ActivityResult): O
121 
122   /**
123    * Fetches the result without starting the activity.
124    *
125    * This can be optionally implemented, and should return null if a result cannot be fetched and
126    * the activity should be started.
127    */
128   protected open fun performGetSynchronousResult(context: Context, args: I): SynchronousResult<O>? =
129     null
130 
131   /** Extracts an argument passed into the current activity using the contract. */
132   fun extractArgument(intent: Intent): I {
133     // Injection point when we are receiving control in an activity
134     AndroidOnboardingGraphLog.log(
135       ActivityNodeExtractArgument(intent.nodeId, this.javaClass, intentToIntentData(intent))
136     )
137 
138     val argument = performExtractArgument(intent)
139 
140     AndroidOnboardingGraphLog.log(
141       ActivityNodeArgumentExtracted(intent.nodeId, this.javaClass, argument)
142     )
143 
144     return argument
145   }
146 
intentToIntentDatanull147   private fun intentToIntentData(intent: Intent): OnboardingEvent.IntentData {
148     val extras =
149       buildMap<String, Any?> {
150         intent.extras?.let { for (key in it.keySet()) put(key, it.get(key)) }
151       }
152 
153     return OnboardingEvent.IntentData(intent.action, extras)
154   }
155 
156   /** Sets a result for this contract. */
setResultnull157   fun setResult(activity: Activity, result: O) {
158     // Injection point when we are returning a result from the current activity
159 
160     AndroidOnboardingGraphLog.log(ActivityNodeSetResult(activity.nodeId, this.javaClass, result))
161     if (result is NodeResult.Failure) {
162       AndroidOnboardingGraphLog.log(ActivityNodeFail(activity.nodeId, result.toString()))
163     }
164 
165     val activityResult = performSetResult(result)
166 
167     val intent = activityResult.data ?: Intent()
168     intent.putExtra(EXTRA_ONBOARDING_NODE_ID, activity.nodeId)
169 
170     activity.setResult(activityResult.resultCode, intent)
171   }
172 
173   class OnboardingLifecycleObserver<I, O>(
174     private var activity: Activity?,
175     private val contract: OnboardingActivityApiContract<I, O>,
176   ) : LifecycleEventObserver {
177     private var isFinishLogged = false
178 
maybeLogFinishnull179     private fun maybeLogFinish() {
180       if (!isFinishLogged && activity?.isFinishing == true) {
181         isFinishLogged = true
182         AndroidOnboardingGraphLog.log(
183           OnboardingEvent.ActivityNodeFinished(
184             activity?.nodeId ?: UNKNOWN_NODE_ID,
185             contract.javaClass,
186           )
187         )
188       }
189     }
190 
onStateChangednull191     override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
192       when (event) {
193         Lifecycle.Event.ON_STOP,
194         Lifecycle.Event.ON_PAUSE -> maybeLogFinish()
195         Lifecycle.Event.ON_DESTROY -> {
196           maybeLogFinish()
197           activity?.nodeId?.let { waitingForResumeActivity.remove(it) }
198           // Clear the activity reference to avoid memory leak.
199           activity = null
200         }
201         Lifecycle.Event.ON_RESUME -> {
202           val nodeId = activity?.nodeId ?: UNKNOWN_NODE_ID
203           if (nodeId == UNKNOWN_NODE_ID) {
204             Log.w(TAG, "${activity?.componentName} does not contain node id.")
205           }
206           AndroidOnboardingGraphLog.log(
207             OnboardingEvent.ActivityNodeResumed(nodeId, contract.javaClass)
208           )
209           val waitingForResume =
210             nodeId != UNKNOWN_NODE_ID && waitingForResumeActivity.contains(nodeId)
211           if (waitingForResume) {
212             val sourceNodeId = waitingForResumeActivity[nodeId] ?: UNKNOWN_NODE_ID
213             AndroidOnboardingGraphLog.log(
214               OnboardingEvent.ActivityNodeResumedAfterLaunch(
215                 sourceNodeId,
216                 nodeId,
217                 contract.javaClass,
218               )
219             )
220             waitingForResumeActivity.remove(nodeId)
221           }
222         }
223         Lifecycle.Event.ON_CREATE,
224         Lifecycle.Event.ON_START,
225         Lifecycle.Event.ON_ANY -> {}
226       }
227     }
228   }
229 
230   /**
231    * Attaches to the specified Activity in onCreate. This validates the intent can be parsed into an
232    * argument.
233    *
234    * @param activity The Activity to attach to.
235    * @param intent The Intent to use (defaults to the Activity's intent).
236    * @return An AttachedResult object with information about the attachment, including the
237    *   validation result.
238    */
239   @CanIgnoreReturnValue
attachnull240   fun attach(activity: ComponentActivity, intent: Intent = activity.intent): AttachedResult {
241     return attach(activity, activity.lifecycle, intent)
242   }
243 
244   /**
245    * Attaches to the specified Activity in onCreate. This validates the intent can be parsed into an
246    * argument.
247    *
248    * A lifecycle should be provided. If the activity does not support lifecycle owner, it should
249    * implement a LifeCycleOwner or migrate to use androidx ComponentActivity.
250    *
251    * @param activity The Activity to attach to.
252    * @param lifecycle The Lifecycle of the activity.
253    * @param intent The Intent to use (defaults to the Activity's intent).
254    * @return An AttachedResult object with information about the attachment, including the
255    *   validation result.
256    */
257   @CanIgnoreReturnValue
attachnull258   fun attach(
259     activity: Activity,
260     lifecycle: Lifecycle?,
261     intent: Intent = activity.intent,
262   ): AttachedResult {
263     val validated = validateInternal(activity, intent)
264     lifecycle?.addObserver(OnboardingLifecycleObserver(activity, this))
265     return AttachedResult(validated)
266   }
267 
268   /**
269    * Validate that the intent can be parsed into an argument.
270    *
271    * <p>When parsing fails, the failure will be recorded so that it can be fixed.
272    *
273    * @param activity the current activity context
274    * @param intent the [Intent] to validate (defaults to the activity's current intent)
275    * @return `true` if the intent is valid, `false` otherwise
276    * @deprecated use [attach].
277    */
278   @CanIgnoreReturnValue
279   @Deprecated("Use attach instead", ReplaceWith("attach(activity, intent)"))
validatenull280   fun validate(activity: ComponentActivity, intent: Intent = activity.intent): Boolean {
281     return validate(activity, activity.lifecycle, intent)
282   }
283 
284   /**
285    * Validate that the intent can be parsed into an argument.
286    *
287    * <p>When parsing fails, the failure will be recorded so that it can be fixed.
288    *
289    * @param activity the current activity context
290    * @param lifecycle the lifecycle of the activity, used for logging purposes
291    * @param intent the [Intent] to validate (defaults to the activity's current intent)
292    * @return `true` if the intent is valid, `false` otherwise
293    */
294   @CanIgnoreReturnValue
295   @Deprecated("Use attach instead", ReplaceWith("attach(activity, lifecycle, intent)"))
validatenull296   fun validate(
297     activity: Activity,
298     lifecycle: Lifecycle?,
299     intent: Intent = activity.intent,
300   ): Boolean {
301     val validated = validateInternal(activity, intent)
302     lifecycle?.addObserver(OnboardingLifecycleObserver(activity, this))
303     return validated
304   }
305 
validateInternalnull306   private fun validateInternal(activity: Activity, intent: Intent): Boolean {
307     AndroidOnboardingGraphLog.log(
308       ActivityNodeValidating(activity.nodeId, this.javaClass, intentToIntentData(intent))
309     )
310     return runCatching { extractArgument(intent) }
311       .onFailure {
312         AndroidOnboardingGraphLog.log(
313           ActivityNodeFailedValidation(
314             nodeId = activity.nodeId,
315             nodeClass = this.javaClass,
316             exception = it,
317             intent = intentToIntentData(intent),
318           )
319         )
320       }
321       .map { true }
322       .getOrDefault(false)
323   }
324 
325   companion object {
326     @Deprecated(
327       message = "Moved",
328       replaceWith =
329         ReplaceWith(
330           "UNKNOWN_NODE_ID",
331           imports = ["com.android.onboarding.contracts.UNKNOWN_NODE_ID"],
332         ),
333     )
334     const val UNKNOWN_NODE: NodeId = UNKNOWN_NODE_ID
335 
336     const val TAG = "OnboardingApiContract"
337 
338     // nodeId to outgoingId
339     private val waitingForResumeActivity: MutableMap<NodeId, NodeId> = mutableMapOf()
340   }
341 }
342 
343 /** Equivalent to [OnboardingActivityApiContract] for contracts which do not return a result. */
344 abstract class VoidOnboardingActivityApiContract<I> : OnboardingActivityApiContract<I, Unit>() {
performSetResultnull345   final override fun performSetResult(result: Unit): ActivityResult {
346     // Does nothing - no result
347     return ActivityResult(resultCode = 0, data = null)
348   }
349 
performParseResultnull350   final override fun performParseResult(result: ActivityResult) {
351     // Does nothing - no result
352   }
353 }
354 
355 /** Equivalent to [OnboardingActivityApiContract] for contracts which do not take arguments. */
356 abstract class ArgumentFreeOnboardingActivityApiContract<O> :
357   OnboardingActivityApiContract<Unit, O>() {
performExtractArgumentnull358   final override fun performExtractArgument(intent: Intent) {
359     // Does nothing - no argument
360   }
361 }
362 
363 /** Equivalent to [VoidOnboardingActivityApiContract] for contracts which do not take arguments. */
364 abstract class ArgumentFreeVoidOnboardingActivityApiContract :
365   VoidOnboardingActivityApiContract<Unit>() {
performExtractArgumentnull366   final override fun performExtractArgument(intent: Intent) {
367     // Does nothing - no argument
368   }
369 
performCreateIntentnull370   final override fun performCreateIntent(context: Context, arg: Unit) = performCreateIntent(context)
371 
372   abstract fun performCreateIntent(context: Context): Intent
373 }
374 
375 /** Returns [true] if the activity is launched using onboarding contract, [false] otherwise. */
376 fun Activity.isLaunchedByOnboardingContract(): Boolean {
377   return intent.hasExtra(EXTRA_ONBOARDING_NODE_ID)
378 }
379