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]