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