Copybara ❤️: Rework IntentScope to fail silently on missing extras

CL: cl/601090633
Bug: 321248710
Bug: 291561726
NO_IFTTT=no need
PiperOrigin-RevId: 601090633
Change-Id: Iee8ef03a1f120585c0475702ab0e91aa629c0dbb
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 4cb0016..773211c 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -6,4 +6,8 @@
   <uses-sdk
       android:minSdkVersion="26"
       android:targetSdkVersion="34" />
+
+  <application>
+    <meta-data android:name="onboarding_compliance_date" android:value="2024-02-02" />
+  </application>
 </manifest>
diff --git a/src/com/android/onboarding/contracts/IntentSerializer.kt b/src/com/android/onboarding/contracts/IntentSerializer.kt
index 760ac81..9706639 100644
--- a/src/com/android/onboarding/contracts/IntentSerializer.kt
+++ b/src/com/android/onboarding/contracts/IntentSerializer.kt
@@ -1,11 +1,6 @@
 package com.android.onboarding.contracts
 
 import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.os.Parcelable
-import androidx.annotation.RequiresApi
 
 /** Marks functions that read or write intent data */
 @DslMarker internal annotation class IntentManipulationDsl
@@ -41,10 +36,6 @@
 
   fun write(intent: Intent, value: V)
 
-  fun writeIfPresent(intent: Intent, value: V?) {
-    value?.let { write(intent, value) }
-  }
-
   fun read(intent: Intent): V
 
   /**
@@ -52,264 +43,23 @@
    * catches it, prints stack trace and returns null
    */
   fun readOrNull(intent: Intent): V? =
-    runCatching { read(intent) }
-      .getOrElse {
-        println(it.stackTraceToString())
-        null
-      }
+    runCatching { read(intent) }.onFailure(Throwable::printStackTrace).getOrNull()
 }
 
 /**
- * A serializer that does not expose [Intent] directly and instead works via [IntentScope]
+ * A serializer that does not expose [Intent] directly and instead works via [NodeAwareIntentScope]
  * abstraction that can be observed
  *
  * TODO This is living as a separate opt-in interface for now, but we should look into incorporating
  * it inside the contract class to make methods enforceable and protected
  */
-interface ScopedIntentSerializer<V> : IntentSerializer<V> {
-  override fun write(intent: Intent, value: V) = IntentScope(intent).write(value)
+interface NodeAwareIntentSerializer<V> : IntentSerializer<V>, NodeAware {
+  fun NodeAwareIntentScope.write(value: V)
 
-  override fun read(intent: Intent): V = IntentScope(intent).read()
+  fun NodeAwareIntentScope.read(): V
 
-  fun IntentScope.write(value: V)
+  override fun write(intent: Intent, value: V): Unit =
+    NodeAwareIntentScope(nodeId, intent).use { it.write(value) }
 
-  fun IntentScope.writeIfPresent(value: V?) {
-    value?.let { write(value) }
-  }
-
-  /** Writes a [value] to the [IntentScope] via a given [serializer] */
-  @IntentManipulationDsl
-  fun <T> IntentScope.write(serializer: ScopedIntentSerializer<T>, value: T): Unit =
-    with(serializer) { write(value) }
-
-  fun <T> IntentScope.writeIfPresent(serializer: ScopedIntentSerializer<T>, value: T?) {
-    value?.let { write(serializer, value) }
-  }
-
-  fun IntentScope.read(): V
-
-  fun IntentScope.readOrNull(): V? =
-    runCatching { read() }
-      .getOrElse {
-        println(it.stackTraceToString())
-        null
-      }
-
-  /** Reads a value [T] from the [IntentScope] via a given [serializer] */
-  @IntentManipulationDsl
-  fun <T> IntentScope.read(serializer: ScopedIntentSerializer<T>): T = with(serializer) { read() }
-
-  /**
-   * Reads a value [T] from the [IntentScope] via a given [serializer] or silently prints errors and
-   * returns null
-   */
-  @IntentManipulationDsl
-  fun <T> IntentScope.readOrNull(serializer: ScopedIntentSerializer<T>): T? =
-    with(serializer) { readOrNull() }
-}
-
-/**
- * An observable abstraction over [Intent] extras.
- *
- * @param androidIntent to wrap
- * @param afterRead function to call after each read
- * @param beforeWrite function to call before each write
- */
-@IntentManipulationDsl
-data class IntentScope(
-  @PublishedApi internal val androidIntent: Intent,
-  @PublishedApi internal val afterRead: (key: String, value: Any?) -> Unit = { _, _ -> },
-  @PublishedApi internal val beforeWrite: (key: String, value: Any?) -> Unit = { _, _ -> },
-) {
-  companion object {
-    const val KEY_DATA = "com.android.onboarding.INTENT_DATA"
-    const val KEY_ACTION = "com.android.onboarding.INTENT_ACTION"
-  }
-
-  /**
-   * Self-reference for more fluid write access
-   *
-   * ```
-   * with(IntentScope) {
-   *   intent[KEY]= "value"
-   * }
-   * ```
-   */
-  @IntentManipulationDsl val intent: IntentScope = this
-
-  /** Provides observable access to [Intent.getAction] */
-  @IntentManipulationDsl
-  var action: String?
-    get() = androidIntent.action.also { afterRead(KEY_ACTION, it) }
-    set(value) {
-      beforeWrite(KEY_ACTION, value)
-      value?.let(androidIntent::setAction)
-    }
-
-  /** Provides observable access to [Intent.getType] */
-  @IntentManipulationDsl
-  var type: String?
-    get() = androidIntent.type.also { afterRead(KEY_ACTION, it) }
-    set(value) {
-      beforeWrite(KEY_ACTION, value)
-      value?.let(androidIntent::setType)
-    }
-
-  /** Provides observable access to [Intent.getData] */
-  @IntentManipulationDsl
-  var data: Uri?
-    get() = androidIntent.data.also { afterRead(KEY_DATA, it) }
-    set(value) {
-      beforeWrite(KEY_DATA, value)
-      value?.let(androidIntent::setData)
-    }
-
-  /** Copy over all [extras] to this [IntentScope] */
-  @IntentManipulationDsl
-  operator fun plusAssign(extras: Bundle) {
-    androidIntent.putExtras(extras)
-  }
-
-  /** Copy over all extras from [other] to this [IntentScope] */
-  @IntentManipulationDsl
-  operator fun plusAssign(other: IntentScope) {
-    androidIntent.putExtras(other.androidIntent)
-  }
-
-  @IntentManipulationDsl operator fun contains(key: String): Boolean = androidIntent.hasExtra(key)
-
-  @IntentManipulationDsl
-  fun intExtraOrNull(key: String): Int? =
-    run { if (contains(key)) androidIntent.getIntExtra(key, 0) else null }
-      .also { afterRead(key, it) }
-
-  @IntentManipulationDsl
-  fun intExtra(key: String): Int = intExtraOrNull(key) ?: missingExtraError(key)
-
-  @IntentManipulationDsl
-  fun stringExtraOrNull(key: String): String? =
-    androidIntent.getStringExtra(key).also { afterRead(key, it) }
-
-  @IntentManipulationDsl
-  fun stringExtra(key: String): String = stringExtraOrNull(key) ?: missingExtraError(key)
-
-  @IntentManipulationDsl
-  fun booleanExtraOrNull(key: String): Boolean? =
-    run { if (contains(key)) androidIntent.getBooleanExtra(key, false) else null }
-      .also { afterRead(key, it) }
-
-  @IntentManipulationDsl
-  fun booleanExtra(key: String): Boolean = booleanExtraOrNull(key) ?: missingExtraError(key)
-
-  @IntentManipulationDsl
-  fun bundleExtraOrNull(key: String): Bundle? =
-    androidIntent.getBundleExtra(key).also { afterRead(key, it) }
-
-  @IntentManipulationDsl
-  fun bundleExtra(key: String): Bundle = bundleExtraOrNull(key) ?: missingExtraError(key)
-
-  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-  @IntentManipulationDsl
-  inline fun <reified T> parcelableExtraOrNull(key: String): T? =
-    androidIntent.getParcelableExtra(key, T::class.java).also { afterRead(key, it) }
-
-  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-  @IntentManipulationDsl
-  inline fun <reified T> parcelableExtra(key: String): T =
-    parcelableExtraOrNull(key) ?: missingExtraError(key)
-
-  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-  @IntentManipulationDsl
-  inline fun <reified T> parcelableArrayExtraOrNull(key: String): Array<T>? =
-    androidIntent.getParcelableArrayExtra(key, T::class.java).also { afterRead(key, it) }
-
-  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-  @IntentManipulationDsl
-  inline fun <reified T> parcelableArrayExtra(key: String): Array<T> =
-    parcelableArrayExtraOrNull(key) ?: missingExtraError(key)
-
-  @IntentManipulationDsl
-  fun missingExtraError(key: String): Nothing = error("Required extra '$key' is missing")
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Boolean?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Byte?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Char?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Short?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Int?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Long?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Float?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Double?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, it) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: String?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, value) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: CharSequence?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, value) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Parcelable?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, value) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Array<out Parcelable>?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, value) }
-  }
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: List<Parcelable>?): IntentScope =
-    set(key, value?.toTypedArray())
-
-  @IntentManipulationDsl
-  operator fun set(key: String, value: Bundle?): IntentScope = apply {
-    beforeWrite(key, value)
-    value?.let { androidIntent.putExtra(key, value) }
-  }
+  override fun read(intent: Intent): V = NodeAwareIntentScope(nodeId, intent).use { it.read() }
 }
diff --git a/src/com/android/onboarding/contracts/NodeAwareIntentScope.kt b/src/com/android/onboarding/contracts/NodeAwareIntentScope.kt
new file mode 100644
index 0000000..e9a3516
--- /dev/null
+++ b/src/com/android/onboarding/contracts/NodeAwareIntentScope.kt
@@ -0,0 +1,380 @@
+package com.android.onboarding.contracts
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.annotation.RequiresApi
+import com.android.onboarding.contracts.NodeAwareIntentScope.IntentExtra.Invalid
+import com.android.onboarding.contracts.NodeAwareIntentScope.IntentExtra.Present
+import com.android.onboarding.nodes.AndroidOnboardingGraphLog
+import com.android.onboarding.nodes.OnboardingGraphLog
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KClass
+import kotlin.reflect.KProperty
+
+/**
+ * @property androidIntent the intent this scope is wrapping for data manipulation
+ * @property strict by default, closing the scope will only fail the node on the graph in case any
+ *   invalid extras are detected without throwing an exception, however in strict mode it will throw
+ *   as well
+ */
+@IntentManipulationDsl
+class NodeAwareIntentScope(
+  @OnboardingNodeId override val nodeId: NodeId,
+  private val androidIntent: Intent,
+  private val strict: Boolean = false,
+) : NodeAware, AutoCloseable {
+  internal sealed interface IntentExtra<V : Any> {
+    data class Present<V : Any>(val value: V) : IntentExtra<V>
+
+    data class Invalid<V : Any>(val reason: String) : IntentExtra<V>
+
+    companion object {
+      operator fun <T : Any> invoke(name: String, kClass: KClass<T>, value: T?): IntentExtra<T> =
+        if (value == null) {
+          Invalid("Intent extra [$name: ${kClass.simpleName}] is missing")
+        } else {
+          Present(value)
+        }
+
+      inline operator fun <reified T : Any> invoke(name: String, value: T?): IntentExtra<T> =
+        invoke(name, T::class, value)
+    }
+  }
+
+  abstract class IntentExtraDelegate<V> internal constructor() : ReadOnlyProperty<Any?, V> {
+    abstract val value: V
+
+    final override fun getValue(thisRef: Any?, property: KProperty<*>): V = value
+
+    companion object {
+      operator fun <V> invoke(provider: () -> V) =
+        object : IntentExtraDelegate<V>() {
+          override val value: V by lazy(provider)
+        }
+    }
+  }
+
+  inner class OptionalIntentExtraDelegate<V : Any>
+  internal constructor(internal val extra: IntentExtra<out V>) : IntentExtraDelegate<V?>() {
+    init {
+      if (extra is Invalid<*>) errors.add(extra.reason)
+    }
+
+    override val value: V?
+      get() =
+        when (extra) {
+          is Present -> extra.value
+          is Invalid -> null
+        }
+
+    @IntentManipulationDsl
+    val required: RequiredIntentExtraDelegate<V>
+      get() = RequiredIntentExtraDelegate(extra)
+  }
+
+  inner class RequiredIntentExtraDelegate<V : Any>
+  internal constructor(internal val extra: IntentExtra<out V>) : IntentExtraDelegate<V>() {
+    init {
+      if (extra is Invalid<*>) errors.add(extra.reason)
+    }
+
+    override val value: V
+      get() =
+        when (extra) {
+          is Present -> extra.value
+          is Invalid -> error("Intent extra cannot be resolved: ${extra.reason}")
+        }
+
+    @IntentManipulationDsl
+    val optional: OptionalIntentExtraDelegate<V>
+      get() = OptionalIntentExtraDelegate(extra)
+  }
+
+  @IntentManipulationDsl
+  inline fun <T> IntentExtraDelegate<T>.validate(
+    crossinline validator: (T) -> Unit
+  ): IntentExtraDelegate<T> = IntentExtraDelegate { value.also(validator) }
+
+  @IntentManipulationDsl
+  inline fun <T, R> IntentExtraDelegate<T>.map(
+    crossinline transform: (T) -> R
+  ): IntentExtraDelegate<R> = IntentExtraDelegate { value.let(transform) }
+
+  /** Similar to [map], but only calls [transform] on non-null value from the receiver */
+  @IntentManipulationDsl
+  inline fun <T, R> IntentExtraDelegate<T?>.mapOrNull(
+    crossinline transform: (T) -> R
+  ): IntentExtraDelegate<R?> = IntentExtraDelegate { value?.let(transform) }
+
+  @IntentManipulationDsl
+  inline fun <T1, T2, R> IntentExtraDelegate<T1>.zip(
+    other: IntentExtraDelegate<T2>,
+    crossinline zip: (T1, T2) -> R,
+  ): IntentExtraDelegate<R> = IntentExtraDelegate { zip(value, other.value) }
+
+  @IntentManipulationDsl
+  infix fun <T, E : IntentExtraDelegate<T>> IntentExtraDelegate<T?>.or(
+    other: E
+  ): IntentExtraDelegate<T> = IntentExtraDelegate { value ?: other.value }
+
+  @IntentManipulationDsl
+  infix fun <T> IntentExtraDelegate<T?>.or(provider: () -> T): IntentExtraDelegate<T> =
+    IntentExtraDelegate {
+      value ?: provider()
+    }
+
+  @IntentManipulationDsl
+  infix fun <T> IntentExtraDelegate<T?>.or(default: T): IntentExtraDelegate<T> =
+    IntentExtraDelegate {
+      value ?: default
+    }
+
+  @IntentManipulationDsl
+  inline fun <T, R> (() -> T).map(crossinline transform: (T) -> R): () -> R = {
+    invoke().let(transform)
+  }
+
+  @IntentManipulationDsl
+  inline fun <T, R> (() -> T?).mapOrNull(crossinline transform: (T) -> R): () -> R? = {
+    invoke()?.let(transform)
+  }
+
+  private val errors = mutableSetOf<String>()
+
+  override fun close() {
+    if (errors.isNotEmpty()) {
+      val reason =
+        errors.joinToString(prefix = "Detected invalid extras:\n\t", separator = "\n\t - ")
+      AndroidOnboardingGraphLog.log(
+        OnboardingGraphLog.OnboardingEvent.ActivityNodeFail(nodeId, reason)
+      )
+      if (strict) error(reason)
+    }
+  }
+
+  // region DSL
+  /**
+   * Self-reference for more fluid write access
+   *
+   * ```
+   * with(IntentScope) {
+   *   intent[KEY] = {"value"}
+   * }
+   * ```
+   */
+  @IntentManipulationDsl val intent: NodeAwareIntentScope = this
+
+  /** Provides observable access to [Intent.getAction] */
+  @IntentManipulationDsl
+  var action: String?
+    get() = androidIntent.action
+    set(value) {
+      value?.let(androidIntent::setAction)
+    }
+
+  /** Provides observable access to [Intent.getType] */
+  @IntentManipulationDsl
+  var type: String?
+    get() = androidIntent.type
+    set(value) {
+      value?.let(androidIntent::setType)
+    }
+
+  /** Provides observable access to [Intent.getData] */
+  @IntentManipulationDsl
+  var data: Uri?
+    get() = androidIntent.data
+    set(value) {
+      value?.let(androidIntent::setData)
+    }
+
+  /** Copy over all [extras] to this [NodeAwareIntentScope] */
+  @IntentManipulationDsl
+  operator fun plusAssign(extras: Bundle) {
+    androidIntent.putExtras(extras)
+  }
+
+  /** Copy over all extras from [other] to this [NodeAwareIntentScope] */
+  @IntentManipulationDsl
+  operator fun plusAssign(other: NodeAwareIntentScope) {
+    androidIntent.putExtras(other.androidIntent)
+  }
+
+  @IntentManipulationDsl operator fun contains(key: String): Boolean = androidIntent.hasExtra(key)
+
+  // getters
+
+  @IntentManipulationDsl
+  fun <T : Any> read(serializer: NodeAwareIntentSerializer<T>): IntentExtraDelegate<T> =
+    RequiredIntentExtraDelegate(with(serializer) { read().let(::Present) })
+
+  @IntentManipulationDsl
+  fun string(name: String): OptionalIntentExtraDelegate<String> =
+    OptionalIntentExtraDelegate(IntentExtra(name, androidIntent.getStringExtra(name)))
+
+  @IntentManipulationDsl
+  fun int(name: String): OptionalIntentExtraDelegate<Int> =
+    OptionalIntentExtraDelegate(
+      IntentExtra(name, name.takeIf(::contains)?.let { androidIntent.getIntExtra(it, 0) })
+    )
+
+  @IntentManipulationDsl
+  fun boolean(name: String): OptionalIntentExtraDelegate<Boolean> =
+    OptionalIntentExtraDelegate(
+      IntentExtra(name, name.takeIf(::contains)?.let { androidIntent.getBooleanExtra(it, false) })
+    )
+
+  @IntentManipulationDsl
+  fun bundle(name: String): OptionalIntentExtraDelegate<Bundle> =
+    OptionalIntentExtraDelegate(IntentExtra(name, androidIntent.getBundleExtra(name)))
+
+  @PublishedApi
+  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+  @IntentManipulationDsl
+  internal fun <T : Any> parcelable(
+    name: String,
+    kClass: KClass<T>,
+  ): OptionalIntentExtraDelegate<T> =
+    OptionalIntentExtraDelegate(
+      IntentExtra(name, kClass, androidIntent.getParcelableExtra(name, kClass.java))
+    )
+
+  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+  @IntentManipulationDsl
+  inline fun <reified T : Any> parcelable(name: String): OptionalIntentExtraDelegate<T> =
+    parcelable(name, T::class)
+
+  @PublishedApi
+  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+  @IntentManipulationDsl
+  internal fun <T : Any> parcelableArray(
+    name: String,
+    kClass: KClass<T>,
+    kClassArray: KClass<Array<T>>,
+  ): OptionalIntentExtraDelegate<Array<T>> =
+    OptionalIntentExtraDelegate(
+      IntentExtra(name, kClassArray, androidIntent.getParcelableArrayExtra(name, kClass.java))
+    )
+
+  @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+  @IntentManipulationDsl
+  inline fun <reified T : Any> parcelableArray(
+    name: String
+  ): OptionalIntentExtraDelegate<Array<T>> = parcelableArray(name, T::class, Array<T>::class)
+
+  // setters
+
+  /** Extracts a given value logging error on failure */
+  @PublishedApi
+  @IntentManipulationDsl
+  internal fun <T : Any> (() -> T).extract(key: String, kClass: KClass<T>): Result<T> =
+    runCatching(::invoke).onFailure {
+      errors.add("Argument value for intent extra [$key: ${kClass.simpleName}] is missing")
+    }
+
+  private inline fun <reified T : Any> (() -> T).extract(key: String): Result<T> =
+    extract(key, T::class)
+
+  /** Extracts a given nullable value ensuring successful [Result] always contains non-null value */
+  @PublishedApi
+  @IntentManipulationDsl
+  internal fun <T : Any> (() -> T?).extractOptional(): Result<T> =
+    runCatching(::invoke).mapCatching(::requireNotNull)
+
+  @JvmName("setSerializer")
+  @IntentManipulationDsl
+  inline operator fun <reified T : Any> set(
+    serializer: NodeAwareIntentSerializer<T>,
+    noinline value: () -> T,
+  ) {
+    value.extract(serializer::class.simpleName ?: "NESTED", T::class).onSuccess {
+      with(serializer) { write(it) }
+    }
+  }
+
+  @JvmName("setSerializerOrNull")
+  @IntentManipulationDsl
+  inline operator fun <reified T : Any> set(
+    serializer: NodeAwareIntentSerializer<T>,
+    noinline value: () -> T?,
+  ) {
+    value.extractOptional().onSuccess { with(serializer) { write(it) } }
+  }
+
+  @JvmName("setString")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> String) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setStringOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> String?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setInt")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Int) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setIntOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Int?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setBoolean")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Boolean) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setBooleanOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Boolean?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setBundle")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Bundle) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setBundleOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Bundle?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setParcelable")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Parcelable) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setParcelableOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Parcelable?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setParcelableArray")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Array<out Parcelable>) {
+    value.extract(key).onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  @JvmName("setParcelableArrayOrNull")
+  @IntentManipulationDsl
+  operator fun set(key: String, value: () -> Array<out Parcelable>?) {
+    value.extractOptional().onSuccess { androidIntent.putExtra(key, it) }
+  }
+
+  // endregion
+}
diff --git a/src/com/android/onboarding/contracts/authmanaged/EmmContract.kt b/src/com/android/onboarding/contracts/authmanaged/EmmContract.kt
index 6c49e4d..1214aac 100644
--- a/src/com/android/onboarding/contracts/authmanaged/EmmContract.kt
+++ b/src/com/android/onboarding/contracts/authmanaged/EmmContract.kt
@@ -9,9 +9,11 @@
 import androidx.annotation.RequiresApi
 import com.android.onboarding.common.AUTH_MANAGED
 import com.android.onboarding.contracts.ContractResult
-import com.android.onboarding.contracts.IntentScope
+import com.android.onboarding.contracts.NodeAwareIntentScope
+import com.android.onboarding.contracts.NodeAwareIntentSerializer
+import com.android.onboarding.contracts.NodeId
 import com.android.onboarding.contracts.OnboardingActivityApiContract
-import com.android.onboarding.contracts.ScopedIntentSerializer
+import com.android.onboarding.contracts.OnboardingNodeId
 import com.android.onboarding.contracts.annotations.OnboardingNode
 import com.android.onboarding.contracts.setupwizard.SuwArguments
 import com.android.onboarding.contracts.setupwizard.SuwArgumentsSerializer
@@ -23,19 +25,49 @@
 
 // LINT.ThenChange(//depot/google3/java/com/google/android/gmscore/integ/libs/common_auth/src/com/google/android/gms/common/auth/ui/ManagedAccountUtil.java)
 
-data class EmmArguments(
-  override val suwArguments: SuwArguments,
-  val account: Account,
-  val options: Bundle = Bundle.EMPTY,
-  val flow: Int? = null,
-  val dmStatus: String? = null,
-  val isSetupWizard: Boolean = false,
-  val suppressDeviceManagement: Boolean = false,
-  val callingPackage: String = "",
-  val isMainUser: Boolean = false,
-  val isUnicornAccount: Boolean = false,
-  val unmanagedWorkProfileMode: Int = UNMANAGED_WORK_PROFILE_MODE_UNSPECIFIED,
-) : WithSuwArguments
+interface EmmArguments : WithSuwArguments {
+  val account: Account
+  val options: Bundle
+  val flow: Int?
+  val dmStatus: String?
+  val isSetupWizard: Boolean
+  val suppressDeviceManagement: Boolean
+  val callingPackage: String
+  val isMainUser: Boolean
+  val isUnicornAccount: Boolean
+  val unmanagedWorkProfileMode: Int
+
+  companion object {
+    @JvmStatic
+    @JvmName("of")
+    operator fun invoke(
+      suwArguments: SuwArguments,
+      account: Account,
+      options: Bundle = Bundle.EMPTY,
+      flow: Int? = null,
+      dmStatus: String? = null,
+      isSetupWizard: Boolean = false,
+      suppressDeviceManagement: Boolean = false,
+      callingPackage: String = "",
+      isMainUser: Boolean = false,
+      isUnicornAccount: Boolean = false,
+      unmanagedWorkProfileMode: Int = UNMANAGED_WORK_PROFILE_MODE_UNSPECIFIED,
+    ): EmmArguments =
+      object : EmmArguments {
+        override val suwArguments = suwArguments
+        override val account = account
+        override val options = options
+        override val flow = flow
+        override val dmStatus = dmStatus
+        override val isSetupWizard = isSetupWizard
+        override val suppressDeviceManagement = suppressDeviceManagement
+        override val callingPackage = callingPackage
+        override val isMainUser = isMainUser
+        override val isUnicornAccount = isUnicornAccount
+        override val unmanagedWorkProfileMode = unmanagedWorkProfileMode
+      }
+  }
+}
 
 sealed interface EmmResult {
   val code: Int
@@ -97,14 +129,16 @@
  * Contract for {@link EmmChimeraActivity}. Note that the component of this contract intent is
  * assumed to be set by the caller if launching via Component
  */
-@OnboardingNode(
-  component = AUTH_MANAGED,
-  name = "Emm",
-  uiType = OnboardingNode.UiType.INVISIBLE,
-)
+@OnboardingNode(component = AUTH_MANAGED, name = "Emm", uiType = OnboardingNode.UiType.INVISIBLE)
 @RequiresApi(Build.VERSION_CODES.Q)
-class EmmContract @Inject constructor(val suwArgumentsSerializer: SuwArgumentsSerializer) :
-  OnboardingActivityApiContract<EmmArguments, EmmResult>(), ScopedIntentSerializer<EmmArguments> {
+class EmmContract
+@Inject
+constructor(
+  @OnboardingNodeId override val nodeId: NodeId,
+  val suwArgumentsSerializer: SuwArgumentsSerializer,
+) :
+  OnboardingActivityApiContract<EmmArguments, EmmResult>(),
+  NodeAwareIntentSerializer<EmmArguments> {
 
   override fun performCreateIntent(context: Context, arg: EmmArguments): Intent = Intent(arg = arg)
 
@@ -140,35 +174,34 @@
       ContractResult.Failure(result.code)
     }
 
-  override fun IntentScope.write(value: EmmArguments) {
-    write(suwArgumentsSerializer, value.suwArguments)
-    intent[EXTRAS.EXTRA_ACCOUNT] = value.account
-    intent[EXTRAS.EXTRA_IS_SETUP_WIZARD] = value.isSetupWizard
-    intent[EXTRAS.EXTRA_SUPPRESS_DEVICE_MANAGEMENT] = value.suppressDeviceManagement
-    intent[EXTRAS.EXTRA_CALLING_PACKAGE] = value.callingPackage
-    intent[EXTRAS.EXTRA_IS_USER_OWNER] = value.isMainUser
-    intent[EXTRAS.EXTRA_DM_STATUS] = value.dmStatus
-    intent[EXTRAS.EXTRA_IS_UNICORN_ACCOUNT] = value.isUnicornAccount
-    intent[EXTRAS.EXTRA_FLOW] = value.flow
-    intent[EXTRAS.EXTRA_OPTIONS] = value.options
-    intent[EXTRAS.EXTRA_UNMANAGED_WORK_PROFILE_MODE] = value.unmanagedWorkProfileMode
+  override fun NodeAwareIntentScope.write(value: EmmArguments) {
+    intent[suwArgumentsSerializer] = value::suwArguments
+    intent[EXTRAS.EXTRA_ACCOUNT] = value::account
+    intent[EXTRAS.EXTRA_IS_SETUP_WIZARD] = value::isSetupWizard
+    intent[EXTRAS.EXTRA_SUPPRESS_DEVICE_MANAGEMENT] = value::suppressDeviceManagement
+    intent[EXTRAS.EXTRA_CALLING_PACKAGE] = value::callingPackage
+    intent[EXTRAS.EXTRA_IS_USER_OWNER] = value::isMainUser
+    intent[EXTRAS.EXTRA_DM_STATUS] = value::dmStatus
+    intent[EXTRAS.EXTRA_IS_UNICORN_ACCOUNT] = value::isUnicornAccount
+    intent[EXTRAS.EXTRA_FLOW] = value::flow
+    intent[EXTRAS.EXTRA_OPTIONS] = value::options
+    intent[EXTRAS.EXTRA_UNMANAGED_WORK_PROFILE_MODE] = value::unmanagedWorkProfileMode
   }
 
-  override fun IntentScope.read(): EmmArguments =
-    EmmArguments(
-      suwArguments = read(suwArgumentsSerializer),
-      account = parcelableExtra(EXTRAS.EXTRA_ACCOUNT),
-      options = parcelableExtra(EXTRAS.EXTRA_OPTIONS) ?: Bundle.EMPTY,
-      flow = intExtraOrNull(EXTRAS.EXTRA_FLOW),
-      dmStatus = stringExtraOrNull(EXTRAS.EXTRA_DM_STATUS),
-      isSetupWizard = booleanExtraOrNull(EXTRAS.EXTRA_IS_SETUP_WIZARD) ?: false,
-      suppressDeviceManagement =
-        booleanExtraOrNull(EXTRAS.EXTRA_SUPPRESS_DEVICE_MANAGEMENT) ?: false,
-      callingPackage = stringExtraOrNull(EXTRAS.EXTRA_CALLING_PACKAGE) ?: "",
-      isMainUser = booleanExtraOrNull(EXTRAS.EXTRA_IS_USER_OWNER) ?: false,
-      isUnicornAccount = booleanExtraOrNull(EXTRAS.EXTRA_IS_UNICORN_ACCOUNT) ?: false,
-      unmanagedWorkProfileMode =
-        intExtraOrNull(EXTRAS.EXTRA_UNMANAGED_WORK_PROFILE_MODE)
-          ?: UNMANAGED_WORK_PROFILE_MODE_UNSPECIFIED,
-    )
+  override fun NodeAwareIntentScope.read(): EmmArguments =
+    object : EmmArguments {
+      override val suwArguments by read(suwArgumentsSerializer)
+      override val account by parcelable<Account>(EXTRAS.EXTRA_ACCOUNT).required
+      override val options by parcelable<Bundle>(EXTRAS.EXTRA_OPTIONS) or Bundle.EMPTY
+      override val flow by int(EXTRAS.EXTRA_FLOW)
+      override val dmStatus by string(EXTRAS.EXTRA_DM_STATUS)
+      override val isSetupWizard by boolean(EXTRAS.EXTRA_IS_SETUP_WIZARD) or false
+      override val suppressDeviceManagement by
+        boolean(EXTRAS.EXTRA_SUPPRESS_DEVICE_MANAGEMENT) or false
+      override val callingPackage by string(EXTRAS.EXTRA_CALLING_PACKAGE) or ""
+      override val isMainUser by boolean(EXTRAS.EXTRA_IS_USER_OWNER) or false
+      override val isUnicornAccount by boolean(EXTRAS.EXTRA_IS_UNICORN_ACCOUNT) or false
+      override val unmanagedWorkProfileMode by
+        int(EXTRAS.EXTRA_UNMANAGED_WORK_PROFILE_MODE) or UNMANAGED_WORK_PROFILE_MODE_UNSPECIFIED
+    }
 }
diff --git a/src/com/android/onboarding/contracts/setupwizard/SuwArguments.kt b/src/com/android/onboarding/contracts/setupwizard/SuwArguments.kt
index 7237b0b..94f11c1 100644
--- a/src/com/android/onboarding/contracts/setupwizard/SuwArguments.kt
+++ b/src/com/android/onboarding/contracts/setupwizard/SuwArguments.kt
@@ -36,15 +36,44 @@
  * @property isSubactivityFirstLaunched Return true if the ActivityWrapper is Created and not start
  *   SubActivity yet
  */
-data class SuwArguments(
-  val isSuwSuggestedActionFlow: Boolean,
-  val isSetupFlow: Boolean,
-  val preDeferredSetup: Boolean,
-  val deferredSetup: Boolean,
-  val firstRun: Boolean,
-  val portalSetup: Boolean,
-  val wizardBundle: Bundle,
-  val theme: String,
-  val hasMultipleUsers: Boolean? = null,
-  val isSubactivityFirstLaunched: Boolean? = null,
-)
+interface SuwArguments {
+  val isSuwSuggestedActionFlow: Boolean
+  val isSetupFlow: Boolean
+  val preDeferredSetup: Boolean
+  val deferredSetup: Boolean
+  val firstRun: Boolean
+  val portalSetup: Boolean
+  val wizardBundle: Bundle
+  val theme: String
+  val hasMultipleUsers: Boolean?
+  val isSubactivityFirstLaunched: Boolean?
+
+  companion object {
+    @JvmStatic
+    @JvmName("of")
+    operator fun invoke(
+      isSuwSuggestedActionFlow: Boolean,
+      isSetupFlow: Boolean,
+      preDeferredSetup: Boolean,
+      deferredSetup: Boolean,
+      firstRun: Boolean,
+      portalSetup: Boolean,
+      wizardBundle: Bundle,
+      theme: String,
+      hasMultipleUsers: Boolean?,
+      isSubactivityFirstLaunched: Boolean?,
+    ): SuwArguments =
+      object : SuwArguments {
+        override val isSuwSuggestedActionFlow = isSuwSuggestedActionFlow
+        override val isSetupFlow = isSetupFlow
+        override val preDeferredSetup = preDeferredSetup
+        override val deferredSetup = deferredSetup
+        override val firstRun = firstRun
+        override val portalSetup = portalSetup
+        override val wizardBundle = wizardBundle
+        override val theme = theme
+        override val hasMultipleUsers = hasMultipleUsers
+        override val isSubactivityFirstLaunched = isSubactivityFirstLaunched
+      }
+  }
+}
diff --git a/src/com/android/onboarding/contracts/setupwizard/SuwArgumentsSerializer.kt b/src/com/android/onboarding/contracts/setupwizard/SuwArgumentsSerializer.kt
index e9dc22e..3ce8a16 100644
--- a/src/com/android/onboarding/contracts/setupwizard/SuwArgumentsSerializer.kt
+++ b/src/com/android/onboarding/contracts/setupwizard/SuwArgumentsSerializer.kt
@@ -1,35 +1,46 @@
 package com.android.onboarding.contracts.setupwizard
 
-import com.android.onboarding.contracts.IntentScope
-import com.android.onboarding.contracts.ScopedIntentSerializer
+import com.android.onboarding.contracts.NodeAwareIntentScope
+import com.android.onboarding.contracts.NodeAwareIntentSerializer
+import com.android.onboarding.contracts.OnboardingNodeId
 import com.google.android.setupcompat.util.WizardManagerHelper
 import javax.inject.Inject
 
-class SuwArgumentsSerializer @Inject constructor() : ScopedIntentSerializer<SuwArguments> {
-  override fun IntentScope.write(value: SuwArguments) {
-    intent[WizardManagerHelper.EXTRA_IS_SUW_SUGGESTED_ACTION_FLOW] = value.isSuwSuggestedActionFlow
-    intent[WizardManagerHelper.EXTRA_IS_SETUP_FLOW] = value.isSetupFlow
-    intent[WizardManagerHelper.EXTRA_IS_PRE_DEFERRED_SETUP] = value.preDeferredSetup
-    intent[WizardManagerHelper.EXTRA_IS_DEFERRED_SETUP] = value.deferredSetup
-    intent[WizardManagerHelper.EXTRA_IS_FIRST_RUN] = value.firstRun
-    intent[WizardManagerHelper.EXTRA_IS_PORTAL_SETUP] = value.portalSetup
-    intent["wizardBundle"] = value.wizardBundle
-    intent[WizardManagerHelper.EXTRA_THEME] = value.theme
-    intent["hasMultipleUsers"] = value.hasMultipleUsers
-    intent["isSubactivityFirstLaunched"] = value.isSubactivityFirstLaunched
+class SuwArgumentsSerializer @Inject constructor(@OnboardingNodeId override val nodeId: Long) :
+  NodeAwareIntentSerializer<SuwArguments> {
+
+  override fun NodeAwareIntentScope.write(value: SuwArguments) {
+    intent[WizardManagerHelper.EXTRA_IS_SUW_SUGGESTED_ACTION_FLOW] = value::isSuwSuggestedActionFlow
+    intent[WizardManagerHelper.EXTRA_IS_SETUP_FLOW] = value::isSetupFlow
+    intent[WizardManagerHelper.EXTRA_IS_PRE_DEFERRED_SETUP] = value::preDeferredSetup
+    intent[WizardManagerHelper.EXTRA_IS_DEFERRED_SETUP] = value::deferredSetup
+    intent[WizardManagerHelper.EXTRA_IS_FIRST_RUN] = value::firstRun
+    intent[WizardManagerHelper.EXTRA_IS_PORTAL_SETUP] = value::portalSetup
+    intent[EXTRA_WIZARD_BUNDLE] = value::wizardBundle
+    intent[WizardManagerHelper.EXTRA_THEME] = value::theme
+    intent[EXTRA_HAS_MULTIPLE_USERS] = value::hasMultipleUsers
+    intent[EXTRA_IS_SUBACTIVITY_FIRST_LAUNCHED] = value::isSubactivityFirstLaunched
   }
 
-  override fun IntentScope.read(): SuwArguments =
-    SuwArguments(
-      isSuwSuggestedActionFlow = booleanExtra("isSuwSuggestedActionFlow"),
-      isSetupFlow = booleanExtra("isSetupFlow"),
-      preDeferredSetup = booleanExtra("preDeferredSetup"),
-      deferredSetup = booleanExtra("deferredSetup"),
-      firstRun = booleanExtra("firstRun"),
-      portalSetup = booleanExtra("portalSetup"),
-      wizardBundle = bundleExtra("wizardBundle"),
-      theme = stringExtra("theme"),
-      hasMultipleUsers = booleanExtraOrNull("hasMultipleUsers"),
-      isSubactivityFirstLaunched = booleanExtraOrNull("isSubactivityFirstLaunched"),
-    )
+  override fun NodeAwareIntentScope.read(): SuwArguments =
+    object : SuwArguments {
+      override val isSuwSuggestedActionFlow by
+        boolean(WizardManagerHelper.EXTRA_IS_SUW_SUGGESTED_ACTION_FLOW).required
+      override val isSetupFlow by boolean(WizardManagerHelper.EXTRA_IS_SETUP_FLOW).required
+      override val preDeferredSetup by
+        boolean(WizardManagerHelper.EXTRA_IS_PRE_DEFERRED_SETUP).required
+      override val deferredSetup by boolean(WizardManagerHelper.EXTRA_IS_DEFERRED_SETUP).required
+      override val firstRun by boolean(WizardManagerHelper.EXTRA_IS_FIRST_RUN).required
+      override val portalSetup by boolean(WizardManagerHelper.EXTRA_IS_PORTAL_SETUP).required
+      override val wizardBundle by bundle(EXTRA_WIZARD_BUNDLE).required
+      override val theme by string(WizardManagerHelper.EXTRA_THEME).required
+      override val hasMultipleUsers by boolean(EXTRA_HAS_MULTIPLE_USERS)
+      override val isSubactivityFirstLaunched by boolean(EXTRA_IS_SUBACTIVITY_FIRST_LAUNCHED)
+    }
+
+  private companion object {
+    const val EXTRA_IS_SUBACTIVITY_FIRST_LAUNCHED = "isSubactivityFirstLaunched"
+    const val EXTRA_HAS_MULTIPLE_USERS = "hasMultipleUsers"
+    const val EXTRA_WIZARD_BUNDLE = "wizardBundle"
+  }
 }
diff --git a/src/com/android/onboarding/contracts/testing/Android.bp b/src/com/android/onboarding/contracts/testing/Android.bp
index 0fc12c9..b9db757 100644
--- a/src/com/android/onboarding/contracts/testing/Android.bp
+++ b/src/com/android/onboarding/contracts/testing/Android.bp
@@ -17,6 +17,8 @@
         "androidx.fragment_fragment-ktx",
         "androidx.appcompat_appcompat",
         "Robolectric_all-target_upstream",
+        "apache-commons-lang3",
+        "kotlin-reflect",
         "truth",
     ],
 }
diff --git a/src/com/android/onboarding/contracts/testing/IntentSerializerTest.kt b/src/com/android/onboarding/contracts/testing/IntentSerializerTest.kt
new file mode 100644
index 0000000..86c7f0e
--- /dev/null
+++ b/src/com/android/onboarding/contracts/testing/IntentSerializerTest.kt
@@ -0,0 +1,21 @@
+package com.android.onboarding.contracts.testing
+
+import com.android.onboarding.contracts.IntentSerializer
+import org.junit.Test
+
+abstract class IntentSerializerTest<V : Any> {
+  protected abstract val target: IntentSerializer<V>
+  protected abstract val data: V
+
+  @Test
+  fun completeArgument_encodesCorrectly() {
+    assertIntentEncodesCorrectly(target, data)
+  }
+}
+
+abstract class NodeAwareIntentSerializerTest<V : Any> : IntentSerializerTest<V>() {
+  @Test
+  fun noExtras_decodingFailsLazily() {
+    assertEmptyIntentDecodingFailsLazily(target)
+  }
+}
diff --git a/src/com/android/onboarding/contracts/testing/OnboardingActivityApiContractTester.kt b/src/com/android/onboarding/contracts/testing/OnboardingActivityApiContractTester.kt
index 5748b9d..3ce6e75 100644
--- a/src/com/android/onboarding/contracts/testing/OnboardingActivityApiContractTester.kt
+++ b/src/com/android/onboarding/contracts/testing/OnboardingActivityApiContractTester.kt
@@ -5,18 +5,75 @@
 import android.content.Intent
 import androidx.test.core.app.ApplicationProvider
 import com.android.onboarding.contracts.IntentSerializer
+import com.android.onboarding.contracts.NodeId
 import com.android.onboarding.contracts.OnboardingActivityApiContract
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlin.reflect.KProperty1
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.jvm.isAccessible
+import org.apache.commons.lang3.ClassUtils.isPrimitiveOrWrapper
 import org.robolectric.Robolectric
 import org.robolectric.Shadows.shadowOf
 
+const val TEST_NODE_ID: NodeId = -666
+
+private fun isPrimitiveArray(obj: Any): Boolean =
+  obj is ByteArray ||
+    obj is CharArray ||
+    obj is ShortArray ||
+    obj is IntArray ||
+    obj is LongArray ||
+    obj is DoubleArray ||
+    obj is FloatArray ||
+    obj is BooleanArray
+
+fun recursiveReflectionEquals(lhs: Any?, rhs: Any?): Boolean {
+  if (lhs == null || rhs == null) return lhs == rhs
+  if (isPrimitiveOrWrapper(lhs::class.java) || isPrimitiveOrWrapper(rhs::class.java)) {
+    return lhs == rhs
+  }
+  if (isPrimitiveArray(lhs) && isPrimitiveArray(rhs)) {
+    return arrayOf(lhs).contentDeepEquals(arrayOf(rhs))
+  }
+  if (lhs is Array<*> && rhs is Array<*>) {
+    return lhs.zip(rhs).none { (l, r) -> !recursiveReflectionEquals(l, r) }
+  }
+  if (lhs is Iterable<*> && rhs is Iterable<*>) {
+    return lhs.zip(rhs).none { (l, r) -> !recursiveReflectionEquals(l, r) }
+  }
+  val leftProps = lhs::class.memberProperties.associateBy(KProperty1<*, *>::name)
+  val rightProps = rhs::class.memberProperties.associateBy(KProperty1<*, *>::name)
+  @Suppress("UNCHECKED_CAST")
+  return leftProps.none search@{ (lName, lProp) ->
+    val rProp = rightProps[lName] ?: return@search false
+    val lValue =
+      (lProp as KProperty1<Any, *>).run {
+        val accessible = isAccessible
+        isAccessible = true
+        val value = get(lhs)
+        isAccessible = accessible
+        value
+      }
+    val rValue =
+      (rProp as KProperty1<Any, *>).run {
+        val accessible = isAccessible
+        isAccessible = true
+        val value = get(rhs)
+        isAccessible = accessible
+        value
+      }
+    !recursiveReflectionEquals(lValue, rValue)
+  }
+}
+
 /** Assert that a contract's arguments encode correctly. */
 fun <I> assertArgumentEncodesCorrectly(contract: OnboardingActivityApiContract<I, *>, argument: I) {
   val context = ApplicationProvider.getApplicationContext<Context>()
   val intent = contract.createIntent(context, argument)
   val out = contract.extractArgument(intent)
 
-  assertThat(out).isEqualTo(argument)
+  assertThat(recursiveReflectionEquals(out, argument)).isTrue()
 }
 
 /**
@@ -27,16 +84,16 @@
 fun <O> assertReturnValueEncodesCorrectly(contract: OnboardingActivityApiContract<*, O>, value: O) {
   val controller = Robolectric.buildActivity(Activity::class.java)
   /*
-  * Cannot use [AutoCloseable::use] since [ActivityController]
-  * does not implement [AutoCloseable] on AOSP
-  */
+   * Cannot use [AutoCloseable::use] since [ActivityController]
+   * does not implement [AutoCloseable] on AOSP
+   */
   try {
     val activity = controller.get()
     contract.setResult(activity, value)
     val shadowActivity = shadowOf(activity)
     val result = contract.parseResult(shadowActivity.resultCode, shadowActivity.resultIntent)
 
-    assertThat(result).isEqualTo(value)
+    assertThat(recursiveReflectionEquals(result, value)).isTrue()
   } finally {
     controller.pause()
     controller.stop()
@@ -50,5 +107,21 @@
   parser.write(intent, obj)
   val out = parser.read(intent)
 
-  assertThat(out).isEqualTo(obj)
+  assertThat(recursiveReflectionEquals(out, obj)).isTrue()
+}
+
+/**
+ * Assert that a given [serializer] decodes an empty [Intent] without throwing an exception (fails
+ * lazily on property access).
+ */
+fun <I : Any> assertEmptyIntentDecodingFailsLazily(serializer: IntentSerializer<I>) {
+  val intent = Intent()
+  val arg = serializer.read(intent)
+  val failures =
+    arg::class
+      .memberProperties
+      .map { @Suppress("UNCHECKED_CAST") (it as KProperty1<I, *>) }
+      .map { runCatching { it.get(arg) } }
+      .filter(Result<*>::isFailure)
+  assertWithMessage("Accessing parsed properties throws lazy errors").that(failures).isNotEmpty()
 }
diff --git a/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml b/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml
index 8272cb5..1932735 100644
--- a/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml
+++ b/src/com/android/onboarding/nodes/testing/testapp/AndroidManifest.xml
@@ -17,6 +17,8 @@
 
   <application
       android:label="Onboarding Graph TestApp" android:theme="@style/Theme.AppCompat.Light" android:taskAffinity="">
+    <meta-data android:name="onboarding_compliance_date" android:value="2024-01-26" />
+
     <activity android:name=".MainActivity" android:exported="false">
     </activity>
 
diff --git a/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt b/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt
index d2711dc..b65a36e 100644
--- a/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt
+++ b/src/com/android/onboarding/nodes/testing/testapp/MainActivity.kt
@@ -33,7 +33,7 @@
     val contracts = arrayOf(RedContract(), BlueContract(), GreenContract())
     val contract = intent?.findExecutingContract(*contracts) ?: RedContract()
 
-    if (contract.validate(this, intent)) {
+    if (contract.attach(this, intent).validated) {
       findViewById<TextView>(R.id.status).text =
         "Received argument: ${contract.extractArgument(intent).arg}"
     }
@@ -43,7 +43,7 @@
       packageManager
         .queryIntentActivities(
           Intent("com.android.onboarding.nodes.testing.testapp.red"),
-          ResolveInfoFlags.of(0)
+          ResolveInfoFlags.of(0),
         )
         .map { it.activityInfo.packageName }
         .toSet()
@@ -61,7 +61,7 @@
       ArrayAdapter(
         this,
         android.R.layout.simple_spinner_dropdown_item,
-        contracts.map { it.javaClass.getAnnotation(OnboardingNode::class.java)?.name ?: "Unknown" }
+        contracts.map { it.javaClass.getAnnotation(OnboardingNode::class.java)?.name ?: "Unknown" },
       )
     contractSpinner.setSelection(contracts.indexOf(contract))
 
@@ -142,7 +142,7 @@
 data class ContractArg(
   val arg: String,
   val targetPackageName: String,
-  val shouldForward: Boolean = false
+  val shouldForward: Boolean = false,
 )
 
 abstract class ColourContract(val colour: Int, private val name: String) :
@@ -164,7 +164,7 @@
     ContractArg(
       intent.getStringExtra("KEY")!!,
       intent.`package` ?: "",
-      intent.hasFlag(FLAG_ACTIVITY_FORWARD_RESULT)
+      intent.hasFlag(FLAG_ACTIVITY_FORWARD_RESULT),
     )
 
   override fun performParseResult(result: ContractResult): String =
diff --git a/src/com/android/onboarding/versions/ComplianceDate.kt b/src/com/android/onboarding/versions/ComplianceDate.kt
index 248b17d..3072b7c 100644
--- a/src/com/android/onboarding/versions/ComplianceDate.kt
+++ b/src/com/android/onboarding/versions/ComplianceDate.kt
@@ -1,5 +1,6 @@
 package com.android.onboarding.versions
 
+import com.android.onboarding.versions.annotations.ChangeId
 import java.time.LocalDate
 
 /**
@@ -41,6 +42,16 @@
   /** True if the package's version is less than {@code version} */
   fun isLessThan(version: ComplianceDate): Boolean = version.complianceDate.isAfter(complianceDate)
 
+  /**
+   * True if the [complianceDate] is greater than or equal to the available date of the [changeId].
+   */
+  fun isAvailable(changeId: ChangeId) =
+    if (changeId.available == ChangeId.NOT_AVAILABLE) {
+      false
+    } else {
+      isAtLeast(changeId.available)
+    }
+
   companion object {
     /**
      * The default for any APK for whom no compliance date is specified.
diff --git a/src/com/android/onboarding/versions/DefaultOnboardingChanges.kt b/src/com/android/onboarding/versions/DefaultOnboardingChanges.kt
index f4b4e9b..7086837 100644
--- a/src/com/android/onboarding/versions/DefaultOnboardingChanges.kt
+++ b/src/com/android/onboarding/versions/DefaultOnboardingChanges.kt
@@ -73,9 +73,8 @@
         context.packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
 
       val complianceDateString =
-        packageInfo.applicationInfo?.metaData?.getString(
-          "onboarding-compliance-date",
-        ) ?: ComplianceDate.EARLIEST_COMPLIANCE_DATE_STRING
+        packageInfo.applicationInfo?.metaData?.getString("onboarding-compliance-date")
+          ?: ComplianceDate.EARLIEST_COMPLIANCE_DATE_STRING
       packageComplianceDates[packageName] = ComplianceDate(complianceDateString)
     } catch (e: NameNotFoundException) {
       Log.w(LOG_TAG, "Could not find package info for $packageName", e)
@@ -121,7 +120,7 @@
       return false
     }
 
-    return Versions.isAvailable(packageComplianceDates[packageName]!!, changeData)
+    return packageComplianceDates[packageName]!!.isAvailable(changeData)
   }
 
   private fun getChangeOverride(packageName: String, changeId: Long): Boolean? {
diff --git a/src/com/android/onboarding/versions/Versions.kt b/src/com/android/onboarding/versions/Versions.kt
index 313494e..af06778 100644
--- a/src/com/android/onboarding/versions/Versions.kt
+++ b/src/com/android/onboarding/versions/Versions.kt
@@ -3,8 +3,6 @@
 import android.content.Context
 import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
-import com.android.onboarding.versions.annotations.ChangeId
-import com.android.onboarding.versions.annotations.ChangeId.Companion.NOT_AVAILABLE
 
 /** The meta-data key used to indicate compliance date. */
 private const val COMPLIANCE_DATE_METADATA_KEY = "onboarding-compliance-date"
@@ -34,17 +32,4 @@
 
     complianceDateString?.let(::ComplianceDate) ?: ComplianceDate()
   }
-
-  companion object {
-    /**
-     * True if the [complianceDate] is greater than or equal to the available date of the
-     * [changeId].
-     */
-    fun isAvailable(complianceDate: ComplianceDate, changeId: ChangeId) =
-      if (changeId.available == NOT_AVAILABLE) {
-        false
-      } else {
-        complianceDate.isAtLeast(changeId.available)
-      }
-  }
 }
diff --git a/src/com/android/onboarding/versions/testing/FakeOnboardingChanges.kt b/src/com/android/onboarding/versions/testing/FakeOnboardingChanges.kt
index 7485eb8..cd36745 100644
--- a/src/com/android/onboarding/versions/testing/FakeOnboardingChanges.kt
+++ b/src/com/android/onboarding/versions/testing/FakeOnboardingChanges.kt
@@ -2,7 +2,6 @@
 
 import com.android.onboarding.versions.ComplianceDate
 import com.android.onboarding.versions.OnboardingChanges
-import com.android.onboarding.versions.Versions
 import com.android.onboarding.versions.annotations.ChangeId
 import com.android.onboarding.versions.annotations.ChangeRadius
 import com.android.onboarding.versions.changes.ALL_CHANGE_IDS
@@ -125,7 +124,7 @@
 
     // The fake does not support the "if it cannot be found then we assume no support" functionality
 
-    return Versions.isAvailable(getComplianceDate(packageName), changeData)
+    return getComplianceDate(packageName).isAvailable(changeData)
   }
 
   private fun extractPackageFromComponent(component: String) = component.split("/", limit = 2)[0]