Merge "Add `contentDeepEquals` to Saved State" into androidx-main
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index fe1c8f0..eae7dfc 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -13,6 +13,7 @@
   @kotlin.jvm.JvmInline public final value class SavedStateReader {
     ctor public SavedStateReader(android.os.Bundle source);
     method public inline operator boolean contains(String key);
+    method public boolean contentDeepEquals(android.os.Bundle other);
     method public inline boolean getBoolean(String key);
     method public inline boolean getBooleanOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Boolean> defaultValue);
     method public inline double getDouble(String key);
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 47dcf78..32f0d13 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -13,6 +13,7 @@
   @kotlin.jvm.JvmInline public final value class SavedStateReader {
     ctor public SavedStateReader(android.os.Bundle source);
     method public inline operator boolean contains(String key);
+    method public boolean contentDeepEquals(android.os.Bundle other);
     method public inline boolean getBoolean(String key);
     method public inline boolean getBooleanOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Boolean> defaultValue);
     method public inline double getDouble(String key);
@@ -45,6 +46,10 @@
     property public final android.os.Bundle source;
   }
 
+  public final class SavedStateReader_androidKt {
+    method @kotlin.PublishedApi internal static boolean contentDeepEquals(android.os.Bundle, android.os.Bundle other);
+  }
+
   public final class SavedStateRegistry {
     method @MainThread public android.os.Bundle? consumeRestoredStateForKey(String key);
     method public androidx.savedstate.SavedStateRegistry.SavedStateProvider? getSavedStateProvider(String key);
diff --git a/savedstate/savedstate/bcv/native/current.txt b/savedstate/savedstate/bcv/native/current.txt
index 1a70510..4625c67 100644
--- a/savedstate/savedstate/bcv/native/current.txt
+++ b/savedstate/savedstate/bcv/native/current.txt
@@ -51,6 +51,7 @@
     final val source // androidx.savedstate/SavedStateReader.source|{}source[0]
         final fun <get-source>(): androidx.savedstate/SavedState // androidx.savedstate/SavedStateReader.source.<get-source>|<get-source>(){}[0]
 
+    final fun contentDeepEquals(androidx.savedstate/SavedState): kotlin/Boolean // androidx.savedstate/SavedStateReader.contentDeepEquals|contentDeepEquals(androidx.savedstate.SavedState){}[0]
     final fun equals(kotlin/Any?): kotlin/Boolean // androidx.savedstate/SavedStateReader.equals|equals(kotlin.Any?){}[0]
     final fun hashCode(): kotlin/Int // androidx.savedstate/SavedStateReader.hashCode|hashCode(){}[0]
     final fun toString(): kotlin/String // androidx.savedstate/SavedStateReader.toString|toString(){}[0]
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
index c17f0fa..120eff2 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
@@ -171,6 +171,8 @@
 
     actual inline operator fun contains(key: String): Boolean = source.containsKey(key)
 
+    actual fun contentDeepEquals(other: SavedState): Boolean = source.contentDeepEquals(other)
+
     @PublishedApi
     internal inline fun <reified T> getSingleResultOrThrow(
         key: String,
@@ -221,3 +223,23 @@
             defaultValue = { defaultValue() },
         )
 }
+
+@PublishedApi
+internal fun SavedState.contentDeepEquals(other: SavedState): Boolean {
+    if (this === other) return true
+    if (this.size() != other.size()) return false
+
+    for (k in this.keySet()) {
+        @Suppress("DEPRECATION") val v1 = this[k]
+        @Suppress("DEPRECATION") val v2 = other[k]
+
+        when {
+            v1 === v2 -> continue
+            v1 == null || v2 == null -> return false
+            v1 is SavedState && v2 is SavedState -> if (!v1.contentDeepEquals(v2)) return false
+            v1 is Array<*> && v2 is Array<*> -> if (!v1.contentDeepEquals(v2)) return false
+            else -> if (v1 != v2) return false
+        }
+    }
+    return true
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
index e3f37ac..611d5d8 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
@@ -245,4 +245,16 @@
      * @return `true` if the [SavedState] contains the [key], `false` otherwise.
      */
     public inline operator fun contains(key: String): Boolean
+
+    /**
+     * Checks if the two specified [SavedState] are *deeply* equal to one another.
+     *
+     * Two [SavedState] are considered deeply equal if they have the same size, and elements at
+     * corresponding keys are deeply equal. That is, if two corresponding elements are nested
+     * [SavedState], they are also compared deeply.
+     *
+     * @param other the object to compare deeply with this.
+     * @return `true` if the two are deeply equal, `false` otherwise.
+     */
+    public fun contentDeepEquals(other: SavedState): Boolean
 }
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
index b40f557..715575f 100644
--- a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
@@ -95,6 +95,89 @@
         assertThat(underTest.read { isEmpty() }).isTrue()
     }
 
+    @Test
+    fun contentDeepEquals_withEqualContent_returnsTrue() {
+        val sharedState = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+        val state1 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+        val state2 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+
+        val contentDeepEquals = state1.read { contentDeepEquals(state2) }
+
+        assertThat(contentDeepEquals).isTrue()
+    }
+
+    @Test
+    fun contentDeepEquals_withMissingKey_returnsFalse() {
+        val sharedState = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+        val state1 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+        val state2 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+
+        val contentDeepEquals = state1.read { contentDeepEquals(state2) }
+
+        assertThat(contentDeepEquals).isFalse()
+    }
+
+    @Test
+    fun contentDeepEquals_withDifferentContent_returnsFalse() {
+        val sharedState = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+        val state1 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+        val state2 = savedState {
+            putFloat(KEY_1, Float.MAX_VALUE)
+            putFloat(KEY_2, Float.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+
+        val contentDeepEquals = state1.read { contentDeepEquals(state2) }
+
+        assertThat(contentDeepEquals).isFalse()
+    }
+
+    @Test
+    fun contentDeepEquals_withEmptyContent_returnsFalse() {
+        val sharedState = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+        val state1 = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+            putSavedState(KEY_3, sharedState)
+        }
+        val state2 = savedState()
+
+        val contentDeepEquals = state1.read { contentDeepEquals(state2) }
+
+        assertThat(contentDeepEquals).isFalse()
+    }
+
     // region getters and setters
     @Test
     fun getBoolean_whenSet_returns() {
@@ -436,6 +519,7 @@
     private companion object {
         const val KEY_1 = "KEY_1"
         const val KEY_2 = "KEY_2"
+        const val KEY_3 = "KEY_3"
         const val STRING_VALUE = "string-value"
         val LIST_INT_VALUE = List(size = 5) { idx -> idx }
         val LIST_STRING_VALUE = List(size = 5) { idx -> "index=$idx" }
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
index 01e3cd9..a7aaf90 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
@@ -163,4 +163,9 @@
             currentValue = { currentValue() },
             defaultValue = { defaultValue() },
         )
+
+    actual fun contentDeepEquals(other: SavedState): Boolean {
+        // Map implements `equals` as a content deep, there is no need to do anything else.
+        return source.map == other.map
+    }
 }