Merge "Don't do special handling for clearFocus on API 27 and below" into androidx-main
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 8769836..097d831 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,14 +1,18 @@
 <component name="InspectionProjectProfileManager">
   <profile version="1.0">
     <option name="myName" value="Project Default" />
-    <inspection_tool class="AndroidLintKotlinPropertyAccess" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
-    <inspection_tool class="AndroidLintLambdaLast" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
-    <inspection_tool class="AndroidLintNoHardKeywords" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
-    <inspection_tool class="AndroidLintSyntheticAccessor" enabled="true" level="WARNING" enabled_by_default="true">
-      <scope name="Compose" level="WARNING" enabled="false" />
-      <scope name="buildSrc" level="WARNING" enabled="false" />
+    <inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="composableFile" value="true" />
     </inspection_tool>
-    <inspection_tool class="AndroidLintUnknownNullness" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
+    <inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
     <inspection_tool class="DeprecatedIsStillUsed" enabled="false" level="WARNING" enabled_by_default="false" />
     <inspection_tool class="Deprecation" enabled="true" level="WARNING" enabled_by_default="true">
       <option name="IGNORE_IMPORT_STATEMENTS" value="false" />
@@ -18,6 +22,18 @@
         <option name="namePattern" value="[A-Za-z\d]+" />
       </scope>
     </inspection_tool>
+    <inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
     <inspection_tool class="JavaDoc" enabled="true" level="WARNING" enabled_by_default="true">
       <option name="TOP_LEVEL_CLASS_OPTIONS">
         <value>
@@ -49,11 +65,47 @@
       <option name="IGNORE_POINT_TO_ITSELF" value="false" />
       <option name="myAdditionalJavadocTags" value="hide" />
     </inspection_tool>
+    <inspection_tool class="JavadocDeclaration" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="ADDITIONAL_TAGS" value="hide" />
+    </inspection_tool>
     <inspection_tool class="JavadocReference" enabled="true" level="ERROR" enabled_by_default="true">
       <option name="REPORT_INACCESSIBLE" value="false" />
     </inspection_tool>
     <inspection_tool class="KDocUnresolvedReference" enabled="true" level="ERROR" enabled_by_default="true" />
     <inspection_tool class="MissingDeprecatedAnnotation" enabled="true" level="ERROR" enabled_by_default="true" />
+    <inspection_tool class="MissingJavadoc" enabled="true" level="WARNING" enabled_by_default="true">
+      <option name="PACKAGE_SETTINGS">
+        <Options>
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+      <option name="MODULE_SETTINGS">
+        <Options>
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+      <option name="TOP_LEVEL_CLASS_SETTINGS">
+        <Options>
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+      <option name="INNER_CLASS_SETTINGS">
+        <Options>
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+      <option name="METHOD_SETTINGS">
+        <Options>
+          <option name="REQUIRED_TAGS" value="@return@param@throws or @exception" />
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+      <option name="FIELD_SETTINGS">
+        <Options>
+          <option name="ENABLED" value="false" />
+        </Options>
+      </option>
+    </inspection_tool>
     <inspection_tool class="NullableProblems" enabled="true" level="ERROR" enabled_by_default="true">
       <option name="REPORT_NULLABLE_METHOD_OVERRIDES_NOTNULL" value="true" />
       <option name="REPORT_NOT_ANNOTATED_METHOD_OVERRIDES_NOTNULL" value="true" />
@@ -65,6 +117,21 @@
       <option name="REPORT_NULLS_PASSED_TO_NON_ANNOTATED_METHOD" value="true" />
       <option name="REPORT_NULLS_PASSED_TO_NOT_NULL_PARAMETER" value="false" />
     </inspection_tool>
+    <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
+    <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
+      <option name="composableFile" value="true" />
+    </inspection_tool>
     <inspection_tool class="PrivatePropertyName" enabled="true" level="WEAK WARNING" enabled_by_default="true">
       <scope name="Compose" level="WEAK WARNING" enabled="true">
         <option name="namePattern" value="_?[A-Za-z\d]+" />
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
new file mode 100644
index 0000000..76f5162
--- /dev/null
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/Insight.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark
+
+import androidx.annotation.RestrictTo
+
+/**
+ * Represents an insight into performance issues detected during a benchmark.
+ *
+ * Provides details about the specific criterion that was violated, along with information about
+ * where and how the violation was observed.
+ *
+ * @param criterion A description of the performance issue, including the expected behavior and any
+ *   relevant thresholds.
+ * @param observed Specific details about when and how the violation occurred, such as the
+ *   iterations where it was observed and any associated values.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // TODO(364598145): generalise
+data class Insight(val criterion: String, val observed: String)
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
index e2b8b01..b5e1574 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/InstrumentationResults.kt
@@ -20,6 +20,7 @@
 import android.util.Log
 import androidx.annotation.RestrictTo
 import androidx.test.platform.app.InstrumentationRegistry
+import java.lang.StringBuilder
 import java.util.Locale
 import org.jetbrains.annotations.TestOnly
 
@@ -63,7 +64,8 @@
         message: String? = null,
         measurements: Measurements? = null,
         iterationTracePaths: List<String>? = null,
-        profilerResults: List<Profiler.ResultFile> = emptyList()
+        profilerResults: List<Profiler.ResultFile> = emptyList(),
+        insights: List<Insight> = emptyList(),
     ) {
         if (warningMessage != null) {
             InstrumentationResults.scheduleIdeWarningOnNextReport(warningMessage)
@@ -74,7 +76,8 @@
                 message = message,
                 measurements = measurements,
                 iterationTracePaths = iterationTracePaths,
-                profilerResults = profilerResults
+                profilerResults = profilerResults,
+                insights = insights
             )
         reportIdeSummary(summaryV1 = summaryPair.summaryV1, summaryV2 = summaryPair.summaryV2)
     }
@@ -168,7 +171,8 @@
         message: String? = null,
         measurements: Measurements? = null,
         iterationTracePaths: List<String>? = null,
-        profilerResults: List<Profiler.ResultFile> = emptyList()
+        profilerResults: List<Profiler.ResultFile> = emptyList(),
+        insights: List<Insight> = emptyList(),
     ): IdeSummaryPair {
         val warningMessage = ideWarningPrefix.ifEmpty { null }
         ideWarningPrefix = ""
@@ -178,7 +182,7 @@
         val linkableIterTraces =
             iterationTracePaths?.map { absolutePath ->
                 Outputs.relativePathFor(absolutePath).replace("(", "\\(").replace(")", "\\)")
-            }
+            } ?: emptyList()
 
         if (measurements != null) {
             require(measurements.isNotEmpty()) { "Require non-empty list of metric results." }
@@ -248,7 +252,7 @@
                 "  $name   min $min,   median $median,   max $max"
             }
             v2metricLines =
-                if (linkableIterTraces != null) {
+                if (linkableIterTraces.isNotEmpty()) {
                     // Per iteration trace paths present, so link min/med/max to respective
                     // iteration traces
                     metricLines { name, min, median, max, result ->
@@ -267,34 +271,72 @@
             v2metricLines = emptyList()
         }
 
-        val v2traceLinks =
-            if (linkableIterTraces != null) {
-                listOf(
-                    "    Traces: Iteration " +
-                        linkableIterTraces
-                            .mapIndexed { index, path -> "[$index](file://$path)" }
-                            .joinToString(" ")
-                )
+        fun markdownFileLink(label: String, outputRelativePath: String): String =
+            "[$label](file://$outputRelativePath)"
+
+        // TODO(353692849): split into methods and remove the v1 format (replace with a v2 getter)
+        val v2lines =
+            if (insights.isEmpty()) {
+                val v2traceLinks =
+                    if (linkableIterTraces.isNotEmpty()) {
+                        listOf(
+                            "    Traces: Iteration " +
+                                linkableIterTraces
+                                    .mapIndexed { index, path -> markdownFileLink("$index", path) }
+                                    .joinToString(" ")
+                        )
+                    } else {
+                        emptyList()
+                    } +
+                        profilerResults.map {
+                            "    ${markdownFileLink(it.label, it.sanitizedOutputRelativePath)}"
+                        }
+                listOfNotNull(warningMessage, testName, message) +
+                    v2metricLines +
+                    v2traceLinks +
+                    "" /* adds \n */
             } else {
-                emptyList()
-            } +
-                profilerResults.map {
-                    "    [${it.label}](file://${it.sanitizedOutputRelativePath})"
+                buildList {
+                    if (warningMessage != null) add(warningMessage)
+                    if (testName != null) add(testName)
+                    if (message != null) add(message)
+                    val tree = TreeBuilder()
+                    if (v2metricLines.isNotEmpty()) {
+                        tree.append("Metrics", 0)
+                        for (metric in v2metricLines) tree.append(metric, 1)
+                    }
+                    if (insights.isNotEmpty()) {
+                        tree.append("App Startup Insights", 0)
+                        for ((criterion, observed) in insights) {
+                            tree.append(criterion, 1)
+                            tree.append(observed, 2)
+                        }
+                    }
+                    if (linkableIterTraces.isNotEmpty() || profilerResults.isNotEmpty()) {
+                        tree.append("Traces", 0)
+                        if (linkableIterTraces.isNotEmpty())
+                            tree.append(
+                                linkableIterTraces
+                                    .mapIndexed { ix, trace -> markdownFileLink("$ix", trace) }
+                                    .joinToString(prefix = "Iteration ", separator = " "),
+                                1
+                            )
+                        for (line in profilerResults) tree.append(
+                            markdownFileLink(line.label, line.sanitizedOutputRelativePath),
+                            1
+                        )
+                    }
+                    addAll(tree.build())
+                    add("")
                 }
-        return IdeSummaryPair(
-            v1lines =
-                listOfNotNull(
-                    warningMessage,
-                    testName,
-                    message,
-                ) + v1metricLines + /* adds \n */ "",
-            v2lines =
-                listOfNotNull(
-                    warningMessage,
-                    testName,
-                    message,
-                ) + v2metricLines + v2traceLinks + /* adds \n */ ""
-        )
+            }
+
+        // TODO(353692849): replace the v1 format with a v2 format regardless insights
+        val v1lines =
+            if (insights.isNotEmpty()) v2lines
+            else listOfNotNull(warningMessage, testName, message) + v1metricLines + /* adds \n */ ""
+
+        return IdeSummaryPair(v1lines = v1lines, v2lines = v2lines)
     }
 
     /**
@@ -340,3 +382,34 @@
         InstrumentationRegistry.getInstrumentation().sendStatus(2, bundle)
     }
 }
+
+/**
+ * Constructs a hierarchical, tree-like representation of data, similar to the output of the 'tree'
+ * command.
+ */
+private class TreeBuilder {
+    private val lines = mutableListOf<StringBuilder>()
+    private val nbsp = '\u00A0'
+
+    fun append(message: String, depth: Int): TreeBuilder {
+        require(depth >= 0)
+
+        // Create a new line for the tree node, with appropriate indentation using spaces.
+        val line = StringBuilder()
+        repeat(depth * 4) { line.append(nbsp) }
+        line.append("└── ")
+        line.append(message)
+        lines.add(line)
+
+        // Update vertical lines (pipes) to visually connect the new node to its parent/sibling.
+        // TODO: Optimize this for deep trees to avoid potential quadratic time complexity.
+        val anchorColumn = depth * 4
+        var i = lines.lastIndex - 1 // start climbing with the first line above the newly added one
+        while (i >= 0 && lines[i].getOrNull(anchorColumn) == nbsp) lines[i--][anchorColumn] = '│'
+        if (i >= 0 && lines[i].getOrNull(anchorColumn) == '└') lines[i][anchorColumn] = '├'
+
+        return this
+    }
+
+    fun build(): List<String> = lines.map { it.toString() }
+}
diff --git a/benchmark/benchmark-macro-junit4/build.gradle b/benchmark/benchmark-macro-junit4/build.gradle
index 6a958d6..508a6da 100644
--- a/benchmark/benchmark-macro-junit4/build.gradle
+++ b/benchmark/benchmark-macro-junit4/build.gradle
@@ -71,9 +71,10 @@
     kotlinOptions {
         // Enable using experimental APIs from within same version group
         freeCompilerArgs += [
+                "-opt-in=androidx.benchmark.ExperimentalBenchmarkConfigApi",
                 "-opt-in=androidx.benchmark.macro.ExperimentalMetricApi",
+                "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi",
                 "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoTraceProcessorApi",
-                "-opt-in=androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi"
         ]
     }
 }
diff --git a/benchmark/benchmark-macro-junit4/src/androidTest/java/androidx/benchmark/macro/junit4/BaselineProfileRuleTest.kt b/benchmark/benchmark-macro-junit4/src/androidTest/java/androidx/benchmark/macro/junit4/BaselineProfileRuleTest.kt
index 53726f0..972f1ae 100644
--- a/benchmark/benchmark-macro-junit4/src/androidTest/java/androidx/benchmark/macro/junit4/BaselineProfileRuleTest.kt
+++ b/benchmark/benchmark-macro-junit4/src/androidTest/java/androidx/benchmark/macro/junit4/BaselineProfileRuleTest.kt
@@ -154,5 +154,5 @@
         }
     }
 
-    private fun isMokeyDevice() = Build.MODEL.contains("mokey")
+    private fun isMokeyDevice() = Build.DEVICE.contains("mokey")
 }
diff --git a/benchmark/benchmark-macro-junit4/src/main/java/androidx/benchmark/macro/junit4/MacrobenchmarkRule.kt b/benchmark/benchmark-macro-junit4/src/main/java/androidx/benchmark/macro/junit4/MacrobenchmarkRule.kt
index d3dd114..7825418 100644
--- a/benchmark/benchmark-macro-junit4/src/main/java/androidx/benchmark/macro/junit4/MacrobenchmarkRule.kt
+++ b/benchmark/benchmark-macro-junit4/src/main/java/androidx/benchmark/macro/junit4/MacrobenchmarkRule.kt
@@ -115,7 +115,7 @@
             compilationMode = compilationMode,
             iterations = iterations,
             startupMode = startupMode,
-            perfettoConfig = null,
+            experimentalConfig = null,
             setupBlock = setupBlock,
             measureBlock = measureBlock
         )
@@ -183,7 +183,7 @@
             metrics = metrics,
             compilationMode = compilationMode,
             iterations = iterations,
-            perfettoConfig = experimentalConfig.perfettoConfig,
+            experimentalConfig = experimentalConfig,
             startupMode = startupMode,
             setupBlock = setupBlock,
             measureBlock = measureBlock
diff --git a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/MacrobenchmarkTest.kt b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/MacrobenchmarkTest.kt
index b41a083..49689d6 100644
--- a/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/MacrobenchmarkTest.kt
+++ b/benchmark/benchmark-macro/src/androidTest/java/androidx/benchmark/macro/MacrobenchmarkTest.kt
@@ -20,6 +20,8 @@
 import android.content.Intent
 import androidx.annotation.RequiresApi
 import androidx.benchmark.DeviceInfo
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.ExperimentalConfig
 import androidx.benchmark.json.BenchmarkData
 import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoHelper
@@ -39,7 +41,7 @@
 
 @RunWith(AndroidJUnit4::class)
 @SmallTest
-@OptIn(ExperimentalMacrobenchmarkApi::class)
+@OptIn(ExperimentalMacrobenchmarkApi::class, ExperimentalBenchmarkConfigApi::class)
 class MacrobenchmarkTest {
 
     @Before
@@ -60,7 +62,7 @@
                     compilationMode = CompilationMode.Ignore(),
                     iterations = 1,
                     startupMode = null,
-                    perfettoConfig = null,
+                    experimentalConfig = null,
                     setupBlock = {},
                     measureBlock = {}
                 )
@@ -81,7 +83,7 @@
                     compilationMode = CompilationMode.Ignore(),
                     iterations = 0, // invalid
                     startupMode = null,
-                    perfettoConfig = null,
+                    experimentalConfig = null,
                     setupBlock = {},
                     measureBlock = {}
                 )
@@ -101,7 +103,7 @@
                 compilationMode = CompilationMode.Ignore(),
                 iterations = 1,
                 startupMode = StartupMode.COLD,
-                perfettoConfig = null,
+                experimentalConfig = null,
                 setupBlock = {},
                 measureBlock = {
                     startActivityAndWait(
@@ -142,7 +144,7 @@
             compilationMode = CompilationMode.DEFAULT,
             iterations = 2,
             startupMode = startupMode,
-            perfettoConfig = null,
+            experimentalConfig = null,
             setupBlock = {
                 opOrder += Block.Setup
                 setupIterations += iteration
@@ -218,7 +220,7 @@
                     compilationMode = CompilationMode.DEFAULT,
                     iterations = 3,
                     startupMode = null,
-                    perfettoConfig = PerfettoConfig.MinimalTest(atraceApps),
+                    experimentalConfig = ExperimentalConfig(PerfettoConfig.MinimalTest(atraceApps)),
                     setupBlock = {},
                     measureBlock = { trace(TRACE_LABEL) { Thread.sleep(2) } }
                 )
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
new file mode 100644
index 0000000..7bca88d
--- /dev/null
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/InsightExtensions.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark.macro
+
+import androidx.benchmark.Insight
+import perfetto.protos.AndroidStartupMetric.SlowStartReason
+import perfetto.protos.AndroidStartupMetric.ThresholdValue.ThresholdUnit
+
+/**
+ * Aggregates raw SlowStartReason results into a list of [Insight]s - in a format easier to display
+ * in the IDE as a summary.
+ *
+ * TODO(353692849): add unit tests
+ */
+internal fun createInsightsIdeSummary(rawInsights: List<List<SlowStartReason>>): List<Insight> {
+    fun createInsightString(
+        criterion: SlowStartReason,
+        observed: List<IndexedValue<SlowStartReason>>
+    ): Insight {
+        observed.forEach {
+            require(it.value.reason_id == criterion.reason_id)
+            require(it.value.expected_value == criterion.expected_value)
+        }
+
+        val expectedValue = requireNotNull(criterion.expected_value)
+        val thresholdUnit = requireNotNull(expectedValue.unit)
+        require(thresholdUnit != ThresholdUnit.THRESHOLD_UNIT_UNSPECIFIED)
+        val unitSuffix =
+            when (thresholdUnit) {
+                ThresholdUnit.NS -> "ns"
+                ThresholdUnit.PERCENTAGE -> "%"
+                ThresholdUnit.COUNT -> " count"
+                ThresholdUnit.TRUE_OR_FALSE -> ""
+                else -> " ${thresholdUnit.toString().lowercase()}"
+            }
+
+        val criterionString = buildString {
+            append(requireNotNull(criterion.reason))
+            val thresholdValue = requireNotNull(expectedValue.value_)
+            append(" (expected: ")
+            if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+                require(thresholdValue in 0L..1L)
+                if (thresholdValue == 0L) append("false")
+                if (thresholdValue == 1L) append("true")
+            } else {
+                if (expectedValue.higher_expected == true) append("> ")
+                if (expectedValue.higher_expected == false) append("< ")
+                append(thresholdValue)
+                append(unitSuffix)
+            }
+            append(")")
+        }
+
+        val observedString =
+            observed.joinToString(" ", "seen in iterations: ") {
+                val actualValue = requireNotNull(it.value.actual_value?.value_)
+                val actualString: String =
+                    if (thresholdUnit == ThresholdUnit.TRUE_OR_FALSE) {
+                        require(actualValue in 0L..1L)
+                        if (actualValue == 0L) "false" else "true"
+                    } else {
+                        "$actualValue$unitSuffix"
+                    }
+                "${it.index}($actualString)"
+            }
+
+        return Insight(criterionString, observedString)
+    }
+
+    // Pivot from List<iteration_id -> insight_list> to List<insight -> iteration_list>
+    // and convert to a format expected in Studio text output.
+    return rawInsights
+        .flatMapIndexed { iterationId, insights -> insights.map { IndexedValue(iterationId, it) } }
+        .groupBy { it.value.reason_id }
+        .values
+        .map { createInsightString(it.first().value, it) }
+}
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
index 1bd63b6..2b83006 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/Macrobenchmark.kt
@@ -26,6 +26,8 @@
 import androidx.benchmark.Arguments
 import androidx.benchmark.ConfigurationError
 import androidx.benchmark.DeviceInfo
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.ExperimentalConfig
 import androidx.benchmark.InstrumentationResults
 import androidx.benchmark.Profiler
 import androidx.benchmark.ResultWriter
@@ -34,13 +36,12 @@
 import androidx.benchmark.conditionalError
 import androidx.benchmark.inMemoryTrace
 import androidx.benchmark.json.BenchmarkData
-import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
 import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig
 import androidx.benchmark.perfetto.PerfettoCapture.PerfettoSdkConfig.InitialProcessState
-import androidx.benchmark.perfetto.PerfettoConfig
 import androidx.benchmark.perfetto.PerfettoTraceProcessor
 import androidx.test.platform.app.InstrumentationRegistry
 import org.junit.Assume.assumeFalse
+import perfetto.protos.AndroidStartupMetric
 
 /** Get package ApplicationInfo, throw if not found. */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -197,7 +198,7 @@
  *
  * This function is a building block for public testing APIs
  */
-@ExperimentalPerfettoCaptureApi
+@ExperimentalBenchmarkConfigApi
 private fun macrobenchmark(
     uniqueName: String,
     className: String,
@@ -208,7 +209,7 @@
     iterations: Int,
     launchWithClearTask: Boolean,
     startupModeMetricHint: StartupMode?,
-    perfettoConfig: PerfettoConfig?,
+    experimentalConfig: ExperimentalConfig?,
     perfettoSdkConfig: PerfettoSdkConfig?,
     setupBlock: MacrobenchmarkScope.() -> Unit,
     measureBlock: MacrobenchmarkScope.() -> Unit
@@ -273,7 +274,7 @@
                 scope = scope,
                 profiler = null, // Don't profile when measuring
                 metrics = metrics,
-                perfettoConfig = perfettoConfig,
+                experimentalConfig = experimentalConfig,
                 perfettoSdkConfig = perfettoSdkConfig,
                 setupBlock = setupBlock,
                 measureBlock = measureBlock
@@ -292,7 +293,7 @@
                     scope = scope,
                     profiler = MethodTracingProfiler(scope),
                     metrics = emptyList(), // Nothing to measure
-                    perfettoConfig = perfettoConfig,
+                    experimentalConfig = experimentalConfig,
                     perfettoSdkConfig = perfettoSdkConfig,
                     setupBlock = setupBlock,
                     measureBlock = measureBlock
@@ -303,11 +304,13 @@
     val tracePaths = mutableListOf<String>()
     val profilerResults = mutableListOf<Profiler.ResultFile>()
     val measurementsList = mutableListOf<List<Metric.Measurement>>()
+    val insightsList = mutableListOf<List<AndroidStartupMetric.SlowStartReason>>()
 
     outputs.forEach {
         tracePaths += it.tracePaths
         profilerResults += it.profilerResults
         measurementsList += it.measurements
+        insightsList += it.insights
     }
 
     // Merge measurements
@@ -327,6 +330,7 @@
             warningMessage = warningMessage,
             testName = uniqueName,
             measurements = measurements,
+            insights = createInsightsIdeSummary(insightsList),
             iterationTracePaths = tracePaths,
             profilerResults = profilerResults
         )
@@ -372,7 +376,7 @@
 
 /** Run a macrobenchmark with the specified StartupMode */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
-@ExperimentalPerfettoCaptureApi
+@ExperimentalBenchmarkConfigApi
 fun macrobenchmarkWithStartupMode(
     uniqueName: String,
     className: String,
@@ -381,7 +385,7 @@
     metrics: List<Metric>,
     compilationMode: CompilationMode,
     iterations: Int,
-    perfettoConfig: PerfettoConfig?,
+    experimentalConfig: ExperimentalConfig?,
     startupMode: StartupMode?,
     setupBlock: MacrobenchmarkScope.() -> Unit,
     measureBlock: MacrobenchmarkScope.() -> Unit
@@ -407,7 +411,7 @@
         compilationMode = compilationMode,
         iterations = iterations,
         startupModeMetricHint = startupMode,
-        perfettoConfig = perfettoConfig,
+        experimentalConfig = experimentalConfig,
         perfettoSdkConfig = perfettoSdkConfig,
         setupBlock = {
             if (startupMode == StartupMode.COLD) {
diff --git a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
index 1d56ae9..5adca1f 100644
--- a/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
+++ b/benchmark/benchmark-macro/src/main/java/androidx/benchmark/macro/MacrobenchmarkPhase.kt
@@ -18,9 +18,10 @@
 
 import android.os.Build
 import android.util.Log
+import androidx.benchmark.ExperimentalBenchmarkConfigApi
+import androidx.benchmark.ExperimentalConfig
 import androidx.benchmark.Profiler
 import androidx.benchmark.inMemoryTrace
-import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
 import androidx.benchmark.perfetto.PerfettoCapture
 import androidx.benchmark.perfetto.PerfettoCaptureWrapper
 import androidx.benchmark.perfetto.PerfettoConfig
@@ -30,6 +31,8 @@
 import androidx.benchmark.perfetto.appendUiState
 import androidx.tracing.trace
 import java.io.File
+import perfetto.protos.AndroidStartupMetric
+import perfetto.protos.TraceMetrics
 
 /** A Profiler being used during a Macro Benchmark Phase. */
 internal interface PhaseProfiler {
@@ -61,11 +64,12 @@
     /** A list of profiler results obtained during a Macrobenchmark Phase. */
     val profilerResults: List<Profiler.ResultFile> = emptyList(),
     /** The list of measurements obtained per-iteration from the Macrobenchmark Phase. */
-    val measurements: List<List<Metric.Measurement>> = emptyList()
+    val measurements: List<List<Metric.Measurement>> = emptyList(),
+    val insights: List<List<AndroidStartupMetric.SlowStartReason>> = emptyList()
 )
 
 /** Run a Macrobenchmark Phase and collect the [PhaseResult]. */
-@ExperimentalPerfettoCaptureApi
+@ExperimentalBenchmarkConfigApi
 internal fun PerfettoTraceProcessor.runPhase(
     uniqueName: String,
     packageName: String,
@@ -75,7 +79,7 @@
     scope: MacrobenchmarkScope,
     profiler: PhaseProfiler?,
     metrics: List<Metric>,
-    perfettoConfig: PerfettoConfig?,
+    experimentalConfig: ExperimentalConfig?,
     perfettoSdkConfig: PerfettoCapture.PerfettoSdkConfig?,
     setupBlock: MacrobenchmarkScope.() -> Unit,
     measureBlock: MacrobenchmarkScope.() -> Unit
@@ -85,6 +89,7 @@
     val perfettoCollector = PerfettoCaptureWrapper()
     val tracePaths = mutableListOf<String>()
     val measurements = mutableListOf<List<Metric.Measurement>>()
+    val insights = mutableListOf<List<AndroidStartupMetric.SlowStartReason>>()
     val profilerResultFiles = mutableListOf<Profiler.ResultFile>()
     try {
         // Configure metrics in the Phase.
@@ -105,7 +110,7 @@
                 perfettoCollector.record(
                     fileLabel = scope.fileLabel,
                     config =
-                        perfettoConfig
+                        experimentalConfig?.perfettoConfig
                             ?: PerfettoConfig.Benchmark(
                                 /**
                                  * Prior to API 24, every package name was joined into a single
@@ -159,10 +164,22 @@
             File(tracePath).apply { appendUiState(uiState) }
 
             // Accumulate measurements
-            measurements +=
-                loadTrace(PerfettoTrace(tracePath)) {
-                    // Extracts the metrics using the perfetto trace processor
-                    inMemoryTrace("extract metrics") {
+            loadTrace(PerfettoTrace(tracePath)) {
+                // Extracts the insights using the perfetto trace processor
+                if (experimentalConfig?.startupInsightsConfig?.isEnabled == true) {
+                    inMemoryTrace("extract insights") {
+                        insights +=
+                            TraceMetrics.ADAPTER.decode(
+                                    queryMetricsProtoBinary(listOf("android_startup"))
+                                )
+                                .android_startup
+                                ?.startup
+                                ?.flatMap { it.slow_start_reason_with_details } ?: emptyList()
+                    }
+                }
+                // Extracts the metrics using the perfetto trace processor
+                inMemoryTrace("extract metrics") {
+                    measurements +=
                         metrics
                             // capture list of Measurements
                             .map {
@@ -178,8 +195,8 @@
                             }
                             // merge together
                             .reduceOrNull() { sum, element -> sum.merge(element) } ?: emptyList()
-                    }
                 }
+            }
         }
     } finally {
         scope.killProcess()
@@ -187,6 +204,7 @@
     return PhaseResult(
         tracePaths = tracePaths,
         profilerResults = profilerResultFiles,
-        measurements = measurements
+        measurements = measurements,
+        insights = insights
     )
 }
diff --git a/buildSrc/lint.xml b/buildSrc/lint.xml
index 1993387..78d7c83 100644
--- a/buildSrc/lint.xml
+++ b/buildSrc/lint.xml
@@ -42,6 +42,7 @@
     <issue id="MissingTestSizeAnnotation" severity="fatal" />
     <issue id="IgnoreClassLevelDetector" severity="fatal" />
     <issue id="WithPluginClasspathUsage" severity="fatal" />
+    <issue id="AutoValueNullnessOverride" severity="fatal" />
     <!-- Disable all lint checks on transformed classes by default. b/283812176 -->
     <issue id="all">
         <ignore path="**/.transforms/**" />
diff --git a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
index 2bd1ab6..ae6e4af 100644
--- a/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
+++ b/buildSrc/private/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
@@ -36,7 +36,6 @@
 import com.android.build.api.dsl.TestExtension
 import com.android.build.api.variant.AndroidComponentsExtension
 import com.android.build.api.variant.ApplicationAndroidComponentsExtension
-import com.android.build.api.variant.HasAndroidTest
 import com.android.build.api.variant.HasDeviceTests
 import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
 import com.android.build.api.variant.LibraryAndroidComponentsExtension
@@ -564,6 +563,7 @@
 
 private fun Project.getTestSourceSetsForAndroid(variant: Variant?): List<FileCollection> {
     val testSourceFileCollections = mutableListOf<FileCollection>()
+    @Suppress("DEPRECATION") // usage of HasAndroidTest
     when (variant) {
         is TestVariant -> {
             // com.android.test modules keep test code in main sourceset
@@ -579,7 +579,7 @@
             // Note, don't have to add kotlin-multiplatform as it is not compatible with
             // com.android.test modules
         }
-        is HasAndroidTest -> {
+        is com.android.build.api.variant.HasAndroidTest -> {
             variant.androidTest?.sources?.java?.all?.let {
                 testSourceFileCollections.add(files(it))
             }
diff --git a/busytown/androidx_host_tests_docker_2004.sh b/busytown/androidx_host_tests_docker_2004.sh
index 6856127..4c0b17b 100755
--- a/busytown/androidx_host_tests_docker_2004.sh
+++ b/busytown/androidx_host_tests_docker_2004.sh
@@ -5,4 +5,9 @@
 
 cd "$(dirname $0)"
 
+impl/build.sh test allHostTests zipOwnersFiles createModuleInfo \
+    -Pandroidx.ignoreTestFailures \
+    -Pandroidx.displayTestOutput=false \
+    "$@"
+
 echo "Completing $0 at $(date)"
diff --git a/busytown/impl/build.sh b/busytown/impl/build.sh
index 2a624ad..5f1ede1 100755
--- a/busytown/impl/build.sh
+++ b/busytown/impl/build.sh
@@ -66,6 +66,9 @@
 
 # export some variables
 ANDROID_HOME=../../prebuilts/fullsdk-linux
+export PATH="$ANDROID_HOME/ndk-bundle/toolchains/llvm/prebuilt/linux-x86_64/python3/bin:$PATH"
+# Remove when b/366010045 is resolved: android platform build requires either en_US.UTF-8 or C.UTF-8 to exist
+export LC_ALL=C.UTF-8
 
 BUILD_STATUS=0
 # enable remote build cache unless explicitly disabled
@@ -77,15 +80,23 @@
 # If our existing native libraries are newer, then we don't downgrade them because
 # something else (like Bash) might be requiring the newer version.
 function areNativeLibsNewEnoughForKonan() {
-  host=`uname`
-  if [[ "$host" == Darwin* ]]; then
+  if [[ "$(uname)" == Darwin* ]]; then
     # we don't have any Macs having native dependencies too old to build KMP/konan
     true
+  elif [[ -f /etc/os-release ]]; then
+    . /etc/os-release
+    version=${VERSION_ID//./}  # Remove dots for comparison
+    if (( version >= 2004 )); then
+      true
+    else
+      # on Ubuntu < 20.04 we check whether we have a sufficiently new GLIBCXX
+      gcc --print-file-name=libstdc++.so.6 | xargs readelf -a -W | grep GLIBCXX_3.4.21 >/dev/null
+    fi
   else
-    # on Linux we check whether we have a sufficiently new GLIBCXX
-    gcc --print-file-name=libstdc++.so.6 | xargs readelf -a -W | grep GLIBCXX_3.4.21 >/dev/null
+    true
   fi
 }
+
 if ! areNativeLibsNewEnoughForKonan; then
   KONAN_HOST_LIBS="$OUT_DIR/konan-host-libs"
   LOG="$KONAN_HOST_LIBS.log"
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
index 0c41ed2..23fbcca 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/Camera2CameraController.kt
@@ -303,19 +303,22 @@
                 session?.disconnect()
                 camera?.disconnect()
             }
-        if (graphConfig.flags.closeCaptureSessionOnDisconnect) {
+        if (
+            graphConfig.flags.abortCapturesOnStop ||
+                graphConfig.flags.closeCaptureSessionOnDisconnect
+        ) {
             // It seems that on certain devices, CameraCaptureSession.close() can block for an
             // extended period of time [1]. Wrap the await call with a timeout to prevent us from
             // getting blocked for too long.
             //
             // [1] b/307594946 - [ANR] at Camera2CameraController.disconnectSessionAndCamera
-            runBlockingWithTimeout(threads.backgroundDispatcher, CLOSE_CAPTURE_SESSION_TIMEOUT_MS) {
+            runBlockingWithTimeout(threads.backgroundDispatcher, DISCONNECT_TIMEOUT_MS) {
                 deferred.await()
             }
         }
     }
 
     companion object {
-        private const val CLOSE_CAPTURE_SESSION_TIMEOUT_MS = 2_000L // 2s
+        private const val DISCONNECT_TIMEOUT_MS = 2_000L // 2s
     }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CompositionSettings.java b/camera/camera-core/src/main/java/androidx/camera/core/CompositionSettings.java
index 9b44add..84241e4 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CompositionSettings.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CompositionSettings.java
@@ -41,7 +41,22 @@
  * cases.
  *
  * <p>The following code snippet demonstrates how to display in Picture-in-Picture mode:
- * <pre>{@code
+ * <pre>
+ *                        16
+ *         --------------------------------
+ *         |               c0             |
+ *         |                              |
+ *         |                              |
+ *         |                              |
+ *         |  ---------                   |  9
+ *         |  |       |                   |
+ *         |  |   c1  |                   |
+ *         |  |       |                   |
+ *         |  ---------                   |
+ *         --------------------------------
+ *         c0: primary camera
+ *         c1: secondary camera
+ *     {@code
  *         ResolutionSelector resolutionSelector = new ResolutionSelector.Builder()
  *                 .setAspectRatioStrategy(
  *                         AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
@@ -73,10 +88,7 @@
  *                         .build(),
  *                 lifecycleOwner);
  *         cameraProvider.bindToLifecycle(ImmutableList.of(primary, secondary));
- * }}</pre>
- *
- * <img src="/images/reference/androidx/camera/camera-core/
- *           concurrent_camera_composition_settings.png"/>
+ * }</pre>
  */
 public class CompositionSettings {
 
diff --git a/car/app/app/api/aidlRelease/current/androidx/car/app/model/ITelephoneKeypadEventListener.aidl b/car/app/app/api/aidlRelease/current/androidx/car/app/model/ITelephoneKeypadEventListener.aidl
new file mode 100644
index 0000000..2282fb7
--- /dev/null
+++ b/car/app/app/api/aidlRelease/current/androidx/car/app/model/ITelephoneKeypadEventListener.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package androidx.car.app.model;
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+interface ITelephoneKeypadEventListener {
+  oneway void onKeyLongPress(int key, androidx.car.app.IOnDoneCallback callback) = 1;
+  oneway void onKeyDown(int key, androidx.car.app.IOnDoneCallback callback) = 2;
+  oneway void onKeyUp(int key, androidx.car.app.IOnDoneCallback callback) = 3;
+}
diff --git a/car/app/app/src/main/stableAidl/androidx/car/app/model/ITelephoneKeypadEventListener.aidl b/car/app/app/src/main/stableAidl/androidx/car/app/model/ITelephoneKeypadEventListener.aidl
new file mode 100644
index 0000000..ac8d2a1
--- /dev/null
+++ b/car/app/app/src/main/stableAidl/androidx/car/app/model/ITelephoneKeypadEventListener.aidl
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.car.app.model;
+
+import androidx.car.app.IOnDoneCallback;
+
+/** Binder for Keypad key events. Keys are defined in the Keypad class. */
+@JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
+oneway interface ITelephoneKeypadEventListener {
+  /** Triggered when a key is long pressed. */
+  void onKeyLongPress(int key, IOnDoneCallback callback) = 1;
+
+  /** Triggered when a key is pushed. */
+  void onKeyDown(int key, IOnDoneCallback callback) = 2;
+
+  /** Triggered when a key is released. */
+  void onKeyUp(int key, IOnDoneCallback callback) = 3;
+}
diff --git a/compose/foundation/foundation-layout/api/current.txt b/compose/foundation/foundation-layout/api/current.txt
index 3b13e1e..45e5e44 100644
--- a/compose/foundation/foundation-layout/api/current.txt
+++ b/compose/foundation/foundation-layout/api/current.txt
@@ -372,6 +372,7 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier consumeWindowInsets(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.PaddingValues paddingValues);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier consumeWindowInsets(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.WindowInsets insets);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onConsumedWindowInsetsChanged(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.WindowInsets,kotlin.Unit> block);
+    method public static androidx.compose.ui.Modifier recalculateWindowInsets(androidx.compose.ui.Modifier);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier windowInsetsPadding(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.WindowInsets insets);
   }
 
diff --git a/compose/foundation/foundation-layout/api/restricted_current.txt b/compose/foundation/foundation-layout/api/restricted_current.txt
index a4873d2..0d70ee8 100644
--- a/compose/foundation/foundation-layout/api/restricted_current.txt
+++ b/compose/foundation/foundation-layout/api/restricted_current.txt
@@ -380,6 +380,7 @@
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier consumeWindowInsets(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.PaddingValues paddingValues);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier consumeWindowInsets(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.WindowInsets insets);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier onConsumedWindowInsetsChanged(androidx.compose.ui.Modifier, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.WindowInsets,kotlin.Unit> block);
+    method public static androidx.compose.ui.Modifier recalculateWindowInsets(androidx.compose.ui.Modifier);
     method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier windowInsetsPadding(androidx.compose.ui.Modifier, androidx.compose.foundation.layout.WindowInsets insets);
   }
 
diff --git a/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsPaddingSample.kt b/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsPaddingSample.kt
index 5c0c570..05fb0580 100644
--- a/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsPaddingSample.kt
+++ b/compose/foundation/foundation-layout/samples/src/main/java/androidx/compose/foundation/layout/samples/WindowInsetsPaddingSample.kt
@@ -22,6 +22,7 @@
 import androidx.annotation.Sampled
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ExperimentalLayoutApi
 import androidx.compose.foundation.layout.MutableWindowInsets
 import androidx.compose.foundation.layout.PaddingValues
@@ -32,6 +33,7 @@
 import androidx.compose.foundation.layout.displayCutoutPadding
 import androidx.compose.foundation.layout.exclude
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.ime
 import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.mandatorySystemGesturesPadding
@@ -39,6 +41,7 @@
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.recalculateWindowInsets
 import androidx.compose.foundation.layout.safeContent
 import androidx.compose.foundation.layout.safeContentPadding
 import androidx.compose.foundation.layout.safeDrawingPadding
@@ -52,7 +55,14 @@
 import androidx.compose.foundation.layout.waterfallPadding
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalDensity
@@ -378,3 +388,71 @@
         }
     }
 }
+
+@Sampled
+@Composable
+fun recalculateWindowInsetsSample() {
+    var hasFirstItem by remember { mutableStateOf(true) }
+    var hasLastItem by remember { mutableStateOf(true) }
+    Column(Modifier.fillMaxSize()) {
+        if (hasFirstItem) {
+            Box(Modifier.weight(1f).fillMaxWidth().background(Color.Magenta))
+        }
+        Box(
+            Modifier.fillMaxWidth() // force a fixed size on the content
+                .recalculateWindowInsets()
+                .weight(1f)
+                .background(Color.Yellow)
+                .safeDrawingPadding()
+        ) {
+            Button(
+                onClick = { hasFirstItem = !hasFirstItem },
+                Modifier.align(Alignment.TopCenter)
+            ) {
+                val action = if (hasFirstItem) "Remove" else "Add"
+                Text("$action First Item")
+            }
+            Button(
+                onClick = { hasLastItem = !hasLastItem },
+                Modifier.align(Alignment.BottomCenter)
+            ) {
+                val action = if (hasLastItem) "Remove" else "Add"
+                Text("$action Last Item")
+            }
+        }
+        if (hasLastItem) {
+            Box(Modifier.weight(1f).fillMaxWidth().background(Color.Cyan))
+        }
+    }
+}
+
+@Sampled
+@Composable
+fun consumeWindowInsetsWithPaddingSample() {
+    // The outer Box uses padding and properly compensates for it by using consumeWindowInsets()
+    Box(
+        Modifier.fillMaxSize()
+            .padding(10.dp)
+            .consumeWindowInsets(WindowInsets(10.dp, 10.dp, 10.dp, 10.dp))
+    ) {
+        Box(Modifier.fillMaxSize().safeContentPadding().background(Color.Blue))
+    }
+}
+
+@Sampled
+@Composable
+fun unconsumedWindowInsetsWithPaddingSample() {
+    // This outer Box is representing a 3rd-party layout that you don't control. It has a
+    // padding, but doesn't properly use consumeWindowInsets()
+    Box(Modifier.padding(10.dp)) {
+        // This is the content that you control. You can make sure that the WindowInsets are correct
+        // so you can pad your content despite the fact that the parent did not
+        // consumeWindowInsets()
+        Box(
+            Modifier.fillMaxSize() // Force a fixed size on the content
+                .recalculateWindowInsets()
+                .safeContentPadding()
+                .background(Color.Blue)
+        )
+    }
+}
diff --git a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt
index ffc24e7..a3a1bc5 100644
--- a/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt
+++ b/compose/foundation/foundation-layout/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/layout/WindowInsetsPaddingTest.kt
@@ -30,14 +30,18 @@
 import androidx.annotation.RequiresApi
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.AbsoluteAlignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.findRootCoordinates
 import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onPlaced
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalLayoutDirection
@@ -47,6 +51,7 @@
 import androidx.compose.ui.test.junit4.AndroidComposeTestRule
 import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.round
 import androidx.compose.ui.viewinterop.AndroidView
 import androidx.core.graphics.Insets as AndroidXInsets
@@ -122,6 +127,158 @@
         }
     }
 
+    @Test
+    fun recalculateWindowInsets() {
+        var coordinates: LayoutCoordinates? = null
+
+        var padding by mutableIntStateOf(10)
+
+        setContent {
+            val paddingDp = with(LocalDensity.current) { padding.toDp() }
+            Box(Modifier.padding(paddingDp)) {
+                Box(Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding()) {
+                    Box(Modifier.fillMaxSize().onGloballyPositioned { coordinates = it })
+                }
+            }
+        }
+
+        rule.waitUntil { coordinates != null }
+        val coords = coordinates!!
+
+        sendInsets(WindowInsetsCompat.Type.systemBars(), AndroidXInsets.of(11, 17, 23, 29))
+
+        rule.waitUntil { // older devices animate the insets
+            rule.runOnUiThread { coords.boundsInRoot().top > 16.99f }
+        }
+
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            val rootSize = coords.findRootCoordinates().size
+            assertThat(bounds.left).isWithin(0.1f).of(11f)
+            assertThat(bounds.top).isWithin(0.1f).of(17f)
+            assertThat(bounds.right).isWithin(0.1f).of(rootSize.width - 23f)
+            assertThat(bounds.bottom).isWithin(0.1f).of(rootSize.height - 29f)
+
+            padding = 5
+        }
+
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            val rootSize = coords.findRootCoordinates().size
+            assertThat(bounds.left).isWithin(0.1f).of(11f)
+            assertThat(bounds.top).isWithin(0.1f).of(17f)
+            assertThat(bounds.right).isWithin(0.1f).of(rootSize.width - 23f)
+            assertThat(bounds.bottom).isWithin(0.1f).of(rootSize.height - 29f)
+
+            padding = 20
+        }
+
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            val rootSize = coords.findRootCoordinates().size
+            assertThat(bounds.left).isWithin(0.1f).of(20f)
+            assertThat(bounds.top).isWithin(0.1f).of(20f)
+            assertThat(bounds.right).isWithin(0.1f).of(rootSize.width - 23f)
+            assertThat(bounds.bottom).isWithin(0.1f).of(rootSize.height - 29f)
+        }
+    }
+
+    @Test
+    fun recalculateWindowInsetsWithMovement() {
+        var coordinates: LayoutCoordinates? = null
+
+        var alignment by mutableStateOf(AbsoluteAlignment.TopLeft)
+        setContent {
+            Box(Modifier.background(Color.Blue)) {
+                val sizeDp = with(LocalDensity.current) { 100.toDp() }
+                Box(
+                    Modifier.size(sizeDp)
+                        .recalculateWindowInsets()
+                        .safeDrawingPadding()
+                        .align(alignment)
+                        .background(Color.Yellow)
+                        .onPlaced { coordinates = it }
+                )
+            }
+        }
+
+        rule.waitUntil { coordinates != null }
+        val coords = coordinates!!
+
+        sendInsets(WindowInsetsCompat.Type.statusBars(), AndroidXInsets.of(11, 17, 23, 29))
+        rule.waitUntil { // older devices animate the insets
+            rule.runOnUiThread { coords.boundsInRoot().top > 16.9f }
+        }
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            assertThat(bounds.left).isWithin(0.1f).of(11f)
+            assertThat(bounds.top).isWithin(0.1f).of(17f)
+            assertThat(bounds.right).isWithin(0.1f).of(100f)
+            assertThat(bounds.bottom).isWithin(0.1f).of(100f)
+
+            alignment = AbsoluteAlignment.BottomRight
+        }
+
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            val rootSize = coords.findRootCoordinates().size
+            assertThat(bounds.left).isWithin(0.1f).of(rootSize.width - 100f)
+            assertThat(bounds.top).isWithin(0.1f).of(rootSize.height - 100f)
+            assertThat(bounds.right).isWithin(0.1f).of(rootSize.width - 23f)
+            assertThat(bounds.bottom).isWithin(0.1f).of(rootSize.height - 29f)
+        }
+    }
+
+    @Test
+    fun recalculateWindowInsetsWithNestedMovement() {
+        val coordinates = mutableStateOf<LayoutCoordinates?>(null)
+
+        var alignment by mutableStateOf(AbsoluteAlignment.TopLeft)
+        var size = 0f
+        setContent {
+            size = with(LocalDensity.current) { 100.dp.toPx() }
+            Box(Modifier.background(Color.Blue)) {
+                Box(Modifier.size(100.dp).align(alignment)) {
+                    Box(Modifier.requiredSize(100.dp)) {
+                        Box(
+                            Modifier.size(100.dp)
+                                .recalculateWindowInsets()
+                                .safeDrawingPadding()
+                                .background(Color.Yellow)
+                                .onPlaced { coordinates.value = it }
+                        )
+                    }
+                }
+            }
+        }
+
+        rule.waitUntil { coordinates.value != null }
+        val coords = coordinates.value!!
+
+        sendInsets(WindowInsetsCompat.Type.statusBars(), AndroidXInsets.of(11, 17, 23, 29))
+        rule.waitUntil { // older devices animate the insets
+            rule.runOnUiThread { coords.boundsInRoot().top > 16.9f }
+        }
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            assertThat(bounds.left).isWithin(1f).of(11f)
+            assertThat(bounds.top).isWithin(1f).of(17f)
+            assertThat(bounds.right).isWithin(1f).of(size)
+            assertThat(bounds.bottom).isWithin(1f).of(size)
+
+            alignment = AbsoluteAlignment.BottomRight
+        }
+
+        rule.runOnIdle {
+            val bounds = coords.boundsInRoot()
+            val rootSize = coords.findRootCoordinates().size
+            assertThat(bounds.left).isWithin(1f).of(rootSize.width - size)
+            assertThat(bounds.top).isWithin(1f).of(rootSize.height - size)
+            assertThat(bounds.right).isWithin(1f).of(rootSize.width - 23f)
+            assertThat(bounds.bottom).isWithin(1f).of(rootSize.height - 29f)
+        }
+    }
+
     private fun sendDisplayCutoutInsets(width: Int, height: Int): WindowInsetsCompat {
         val centerWidth = width / 2
         val centerHeight = height / 2
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AspectRatio.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AspectRatio.kt
index a94f9eb..0638142 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AspectRatio.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/AspectRatio.kt
@@ -31,7 +31,6 @@
 import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.isSatisfiedBy
 import androidx.compose.ui.util.fastRoundToInt
 
 /**
@@ -163,19 +162,19 @@
 
     private fun Constraints.findSize(): IntSize {
         if (!matchHeightConstraintsFirst) {
-            tryMaxWidth().also { if (it != IntSize.Zero) return it }
-            tryMaxHeight().also { if (it != IntSize.Zero) return it }
-            tryMinWidth().also { if (it != IntSize.Zero) return it }
-            tryMinHeight().also { if (it != IntSize.Zero) return it }
+            tryMaxWidth(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMaxHeight(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMinWidth(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMinHeight(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
             tryMaxWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
             tryMaxHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
             tryMinWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
             tryMinHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
         } else {
-            tryMaxHeight().also { if (it != IntSize.Zero) return it }
-            tryMaxWidth().also { if (it != IntSize.Zero) return it }
-            tryMinHeight().also { if (it != IntSize.Zero) return it }
-            tryMinWidth().also { if (it != IntSize.Zero) return it }
+            tryMaxHeight(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMaxWidth(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMinHeight(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
+            tryMinWidth(enforceConstraints = true).also { if (it != IntSize.Zero) return it }
             tryMaxHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
             tryMaxWidth(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
             tryMinHeight(enforceConstraints = false).also { if (it != IntSize.Zero) return it }
@@ -184,55 +183,57 @@
         return IntSize.Zero
     }
 
-    private fun Constraints.tryMaxWidth(enforceConstraints: Boolean = true): IntSize {
+    private fun Constraints.tryMaxWidth(enforceConstraints: Boolean): IntSize {
         val maxWidth = this.maxWidth
         if (maxWidth != Constraints.Infinity) {
             val height = (maxWidth / aspectRatio).fastRoundToInt()
             if (height > 0) {
-                val size = IntSize(maxWidth, height)
-                if (!enforceConstraints || isSatisfiedBy(size)) {
-                    return size
+                if (!enforceConstraints || isSatisfiedBy(maxWidth, height)) {
+                    return IntSize(maxWidth, height)
                 }
             }
         }
         return IntSize.Zero
     }
 
-    private fun Constraints.tryMaxHeight(enforceConstraints: Boolean = true): IntSize {
+    private fun Constraints.tryMaxHeight(enforceConstraints: Boolean): IntSize {
         val maxHeight = this.maxHeight
         if (maxHeight != Constraints.Infinity) {
             val width = (maxHeight * aspectRatio).fastRoundToInt()
             if (width > 0) {
-                val size = IntSize(width, maxHeight)
-                if (!enforceConstraints || isSatisfiedBy(size)) {
-                    return size
+                if (!enforceConstraints || isSatisfiedBy(width, maxHeight)) {
+                    return IntSize(width, maxHeight)
                 }
             }
         }
         return IntSize.Zero
     }
 
-    private fun Constraints.tryMinWidth(enforceConstraints: Boolean = true): IntSize {
+    private fun Constraints.tryMinWidth(enforceConstraints: Boolean): IntSize {
         val minWidth = this.minWidth
         val height = (minWidth / aspectRatio).fastRoundToInt()
         if (height > 0) {
-            val size = IntSize(minWidth, height)
-            if (!enforceConstraints || isSatisfiedBy(size)) {
-                return size
+            if (!enforceConstraints || isSatisfiedBy(minWidth, height)) {
+                return IntSize(minWidth, height)
             }
         }
         return IntSize.Zero
     }
 
-    private fun Constraints.tryMinHeight(enforceConstraints: Boolean = true): IntSize {
+    private fun Constraints.tryMinHeight(enforceConstraints: Boolean): IntSize {
         val minHeight = this.minHeight
         val width = (minHeight * aspectRatio).fastRoundToInt()
         if (width > 0) {
-            val size = IntSize(width, minHeight)
-            if (!enforceConstraints || isSatisfiedBy(size)) {
-                return size
+            if (!enforceConstraints || isSatisfiedBy(width, minHeight)) {
+                return IntSize(width, minHeight)
             }
         }
         return IntSize.Zero
     }
 }
+
+/** Takes a size and returns whether it satisfies the current constraints. */
+@Stable
+internal fun Constraints.isSatisfiedBy(width: Int, height: Int): Boolean {
+    return width in minWidth..maxWidth && height in minHeight..maxHeight
+}
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt
index 9aff562..73d7417 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.foundation.layout
 
+import androidx.collection.MutableScatterMap
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
@@ -75,31 +76,28 @@
     )
 }
 
-private fun cacheFor(propagateMinConstraints: Boolean) =
-    HashMap<Alignment, MeasurePolicy>(9).apply {
-        fun putAlignment(it: Alignment) {
-            put(it, BoxMeasurePolicy(it, propagateMinConstraints))
-        }
-        putAlignment(Alignment.TopStart)
-        putAlignment(Alignment.TopCenter)
-        putAlignment(Alignment.TopEnd)
-        putAlignment(Alignment.CenterStart)
-        putAlignment(Alignment.Center)
-        putAlignment(Alignment.CenterEnd)
-        putAlignment(Alignment.BottomStart)
-        putAlignment(Alignment.BottomCenter)
-        putAlignment(Alignment.BottomEnd)
+private fun cacheFor(propagate: Boolean) =
+    MutableScatterMap<Alignment, MeasurePolicy>(9).apply {
+        this[Alignment.TopStart] = BoxMeasurePolicy(Alignment.TopStart, propagate)
+        this[Alignment.TopCenter] = BoxMeasurePolicy(Alignment.TopCenter, propagate)
+        this[Alignment.TopEnd] = BoxMeasurePolicy(Alignment.TopEnd, propagate)
+        this[Alignment.CenterStart] = BoxMeasurePolicy(Alignment.CenterStart, propagate)
+        this[Alignment.Center] = BoxMeasurePolicy(Alignment.Center, propagate)
+        this[Alignment.CenterEnd] = BoxMeasurePolicy(Alignment.CenterEnd, propagate)
+        this[Alignment.BottomStart] = BoxMeasurePolicy(Alignment.BottomStart, propagate)
+        this[Alignment.BottomCenter] = BoxMeasurePolicy(Alignment.BottomCenter, propagate)
+        this[Alignment.BottomEnd] = BoxMeasurePolicy(Alignment.BottomEnd, propagate)
     }
 
-private val cache1 = cacheFor(true)
-private val cache2 = cacheFor(false)
+private val Cache1 = cacheFor(true)
+private val Cache2 = cacheFor(false)
 
 @PublishedApi
 internal fun maybeCachedBoxMeasurePolicy(
     alignment: Alignment,
     propagateMinConstraints: Boolean
 ): MeasurePolicy {
-    val cache = if (propagateMinConstraints) cache1 else cache2
+    val cache = if (propagateMinConstraints) Cache1 else Cache2
     return cache[alignment] ?: BoxMeasurePolicy(alignment, propagateMinConstraints)
 }
 
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
index 0a9e390..3e54570 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/RowColumnMeasurePolicy.kt
@@ -132,7 +132,7 @@
                                 if (mainAxisMax == Constraints.Infinity) {
                                     Constraints.Infinity
                                 } else {
-                                    remaining.coerceAtLeast(0)
+                                    remaining.fastCoerceAtLeast(0)
                                 },
                             crossAxisMax = crossAxisDesiredSize ?: crossAxisMax
                         )
@@ -251,9 +251,10 @@
     // Compute the Row or Column size and position the children.
     val mainAxisLayoutSize = max((fixedSpace + weightedSpace).fastCoerceAtLeast(0), mainAxisMin)
     val crossAxisLayoutSize =
-        max(
+        maxOf(
             crossAxisSpace,
-            max(crossAxisMin, beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine)
+            crossAxisMin,
+            beforeCrossAxisAlignmentLine + afterCrossAxisAlignmentLine
         )
     val mainAxisPositions = IntArray(subSize)
     populateMainAxisPositions(
diff --git a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
index 070aaa0..f916aad 100644
--- a/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
+++ b/compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/WindowInsetsPadding.kt
@@ -23,20 +23,37 @@
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.IntrinsicMeasurable
+import androidx.compose.ui.layout.IntrinsicMeasureScope
+import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.LayoutModifier
 import androidx.compose.ui.layout.Measurable
 import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.layout.findRootCoordinates
+import androidx.compose.ui.layout.positionInRoot
 import androidx.compose.ui.modifier.ModifierLocalConsumer
+import androidx.compose.ui.modifier.ModifierLocalMap
+import androidx.compose.ui.modifier.ModifierLocalModifierNode
 import androidx.compose.ui.modifier.ModifierLocalProvider
 import androidx.compose.ui.modifier.ModifierLocalReadScope
 import androidx.compose.ui.modifier.ProvidableModifierLocal
+import androidx.compose.ui.modifier.modifierLocalMapOf
 import androidx.compose.ui.modifier.modifierLocalOf
+import androidx.compose.ui.node.GlobalPositionAwareModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.invalidatePlacement
+import androidx.compose.ui.platform.InspectorInfo
 import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
 import androidx.compose.ui.unit.offset
+import androidx.compose.ui.unit.round
+import androidx.compose.ui.util.fastRoundToInt
 
 /**
  * Adds padding so that the content doesn't enter [insets] space.
@@ -319,6 +336,34 @@
  */
 expect fun Modifier.mandatorySystemGesturesPadding(): Modifier
 
+/**
+ * This recalculates the [WindowInsets] based on the size and position. This only works when
+ * [Constraints] have [fixed width][Constraints.hasFixedWidth] and
+ * [fixed height][Constraints.hasFixedHeight]. This can be accomplished, for example, by having
+ * [Modifier.size], or [Modifier.fillMaxSize], or other size modifier before
+ * [recalculateWindowInsets]. If the [Constraints] sizes aren't fixed, [recalculateWindowInsets]
+ * won't adjust the [WindowInsets] and won't have any affect on layout.
+ *
+ * [recalculateWindowInsets] is useful when the parent does not call [consumeWindowInsets] when it
+ * aligns a child. For example, a [Column] with two children should have different [WindowInsets]
+ * for each child. The top item should exclude insets below its bottom and the bottom item should
+ * exclude the top insets, but the Column can't assign different insets for different children.
+ *
+ * @sample androidx.compose.foundation.layout.samples.recalculateWindowInsetsSample
+ *
+ * Another use is when a parent doesn't properly [consumeWindowInsets] for all space that it
+ * consumes. For example, a 3rd-party container has padding that doesn't properly use
+ * [consumeWindowInsets].
+ *
+ * @sample androidx.compose.foundation.layout.samples.unconsumedWindowInsetsWithPaddingSample
+ *
+ * In most cases you should not need to use this API, and the parent should instead use
+ * [consumeWindowInsets] to provide the correct values
+ *
+ * @sample androidx.compose.foundation.layout.samples.consumeWindowInsetsWithPaddingSample
+ */
+fun Modifier.recalculateWindowInsets(): Modifier = this.then(RecalculateWindowInsetsModifierElement)
+
 internal val ModifierLocalConsumedWindowInsets = modifierLocalOf { WindowInsets(0, 0, 0, 0) }
 
 internal class InsetsPaddingModifier(private val insets: WindowInsets) :
@@ -464,3 +509,114 @@
 
     override fun hashCode(): Int = insets.hashCode()
 }
+
+private object RecalculateWindowInsetsModifierElement :
+    ModifierNodeElement<RecalculateWindowInsetsModifierNode>() {
+    override fun create(): RecalculateWindowInsetsModifierNode =
+        RecalculateWindowInsetsModifierNode()
+
+    override fun hashCode(): Int = 0
+
+    override fun equals(other: Any?): Boolean = other === this
+
+    override fun update(node: RecalculateWindowInsetsModifierNode) {}
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "recalculateWindowInsets"
+    }
+}
+
+private class RecalculateWindowInsetsModifierNode :
+    Modifier.Node(),
+    ModifierLocalModifierNode,
+    LayoutModifierNode,
+    GlobalPositionAwareModifierNode {
+    val insets = ValueInsets(InsetsValues(0, 0, 0, 0), "reset")
+    var oldPosition = IntOffset.Zero
+
+    override val providedValues: ModifierLocalMap =
+        modifierLocalMapOf(ModifierLocalConsumedWindowInsets to insets)
+
+    override val shouldAutoInvalidate: Boolean
+        get() = false
+
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints
+    ): MeasureResult {
+        return if (!constraints.hasFixedWidth || !constraints.hasFixedHeight) {
+            // We can't provide the modifier local value.
+            // We'll fall back to measuring the contents without providing the value
+            provide(ModifierLocalConsumedWindowInsets, ModifierLocalConsumedWindowInsets.current)
+            val placeable = measurable.measure(constraints)
+            layout(placeable.width, placeable.height) { placeable.place(0, 0) }
+        } else {
+            val width = constraints.maxWidth
+            val height = constraints.maxHeight
+            layout(width, height) {
+                val coordinates = coordinates
+                coordinates?.let { oldPosition = it.positionInRoot().round() }
+                val windowInsets =
+                    if (coordinates == null) {
+                        // We don't know where we are, so can't reset the value. Use the old value.
+                        ModifierLocalConsumedWindowInsets.current
+                    } else {
+                        val topLeft = coordinates.positionInRoot()
+                        val size = coordinates.size
+                        val bottomRight =
+                            coordinates.localToRoot(
+                                Offset(size.width.toFloat(), size.height.toFloat())
+                            )
+                        val root = coordinates.findRootCoordinates()
+                        val rootSize = root.size
+                        val left = topLeft.x.fastRoundToInt()
+                        val top = topLeft.y.fastRoundToInt()
+                        val right = rootSize.width - bottomRight.x.fastRoundToInt()
+                        val bottom = rootSize.height - bottomRight.y.fastRoundToInt()
+                        val oldValues = insets.value
+                        if (
+                            oldValues.left != left ||
+                                oldValues.top != top ||
+                                oldValues.right != right ||
+                                oldValues.bottom != bottom
+                        ) {
+                            insets.value = InsetsValues(left, top, right, bottom)
+                        }
+                        insets
+                    }
+                provide(ModifierLocalConsumedWindowInsets, windowInsets)
+                val placeable = measurable.measure(Constraints.fixed(width, height))
+                placeable.place(0, 0)
+            }
+        }
+    }
+
+    override fun IntrinsicMeasureScope.minIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int
+    ): Int = measurable.minIntrinsicHeight(width)
+
+    override fun IntrinsicMeasureScope.minIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int
+    ): Int = measurable.minIntrinsicWidth(height)
+
+    override fun IntrinsicMeasureScope.maxIntrinsicHeight(
+        measurable: IntrinsicMeasurable,
+        width: Int
+    ): Int = measurable.maxIntrinsicHeight(width)
+
+    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
+        measurable: IntrinsicMeasurable,
+        height: Int
+    ): Int = measurable.maxIntrinsicWidth(height)
+
+    override fun onGloballyPositioned(coordinates: LayoutCoordinates) {
+        val newPosition = coordinates.positionInRoot().round()
+        val hasMoved = oldPosition != newPosition
+        oldPosition = newPosition
+        if (hasMoved) {
+            invalidatePlacement()
+        }
+    }
+}
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index bc4dd47..1daca4d 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -107,6 +107,11 @@
     method public void update(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0<kotlin.Unit>? onLongClick, kotlin.jvm.functions.Function0<kotlin.Unit>? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role);
   }
 
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class ComposeFoundationFlags {
+    field public static final androidx.compose.foundation.ComposeFoundationFlags INSTANCE;
+    field public static boolean RemoveBasicTextGraphicsLayerEnabled;
+  }
+
   public final class DarkThemeKt {
     method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public static boolean isSystemInDarkTheme();
   }
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index dbc823f..2f9441b 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -107,6 +107,11 @@
     method public void update(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String? onLongClickLabel, kotlin.jvm.functions.Function0<kotlin.Unit>? onLongClick, kotlin.jvm.functions.Function0<kotlin.Unit>? onDoubleClick, androidx.compose.foundation.interaction.MutableInteractionSource? interactionSource, androidx.compose.foundation.IndicationNodeFactory? indicationNodeFactory, boolean enabled, String? onClickLabel, androidx.compose.ui.semantics.Role? role);
   }
 
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public final class ComposeFoundationFlags {
+    field public static final androidx.compose.foundation.ComposeFoundationFlags INSTANCE;
+    field public static boolean RemoveBasicTextGraphicsLayerEnabled;
+  }
+
   public final class DarkThemeKt {
     method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public static boolean isSystemInDarkTheme();
   }
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextGraphicsLayerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextGraphicsLayerTest.kt
index 042d31c..d5ebe9f 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextGraphicsLayerTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text/BasicTextGraphicsLayerTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.foundation.text
 
+import androidx.compose.foundation.layout.Column
 import androidx.compose.ui.layout.GraphicLayerInfo
 import androidx.compose.ui.test.SemanticsNodeInteraction
 import androidx.compose.ui.test.junit4.createComposeRule
@@ -35,19 +36,19 @@
     @get:Rule val rule = createComposeRule()
 
     @Test
-    fun modifiersExposeGraphicsLayer() {
-        rule.setContent { BasicText("Ok") }
-        // ui-inspector 3d view requires this to be emitted
+    fun modifiersDoNotExposeGraphicsLayer() {
+        // Something that wraps the `BasicText` is required to distinguish the root graphicsLayer.
+        rule.setContent { Column { BasicText("Ok") } }
         val owners = rule.onNodeWithText("Ok").fetchGraphicsLayerOwnerViewId()
-        assertThat(owners).hasSize(1)
+        assertThat(owners).hasSize(0)
     }
 
     @Test
-    fun modifiersExposeGraphicsLayer_annotatedString() {
-        rule.setContent { BasicText(AnnotatedString("Ok")) }
-        // ui-inspector 3d view requires this to be emitted
+    fun modifiersDoNotExposeGraphicsLayer_annotatedString() {
+        // Something that wraps the `BasicText` is required to distinguish the root graphicsLayer.
+        rule.setContent { Column { BasicText(AnnotatedString("Ok")) } }
         val owners = rule.onNodeWithText("Ok").fetchGraphicsLayerOwnerViewId()
-        assertThat(owners).hasSize(1)
+        assertThat(owners).hasSize(0)
     }
 }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt
new file mode 100644
index 0000000..fbd8781
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/ComposeFoundationFlags.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation
+
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.ui.graphics.graphicsLayer
+import kotlin.jvm.JvmField
+
+/**
+ * This is a collection of flags which are used to guard against regressions in some of the
+ * "riskier" refactors or new feature support that is added to this module. These flags are always
+ * "on" in the published artifact of this module, however these flags allow end consumers of this
+ * module to toggle them "off" in case this new path is causing a regression.
+ *
+ * These flags are considered temporary, and there should be no expectation for these flags be
+ * around for an extended period of time. If you have a regression that one of these flags fixes, it
+ * is strongly encouraged for you to file a bug ASAP.
+ *
+ * **Usage:**
+ *
+ * In order to turn a feature off in a debug environment, it is recommended to set this to false in
+ * as close to the initial loading of the application as possible. Changing this value after compose
+ * library code has already been loaded can result in undefined behavior.
+ *
+ *      class MyApplication : Application() {
+ *          override fun onCreate() {
+ *              ComposeFoundationFlags.SomeFeatureEnabled = false
+ *              super.onCreate()
+ *          }
+ *      }
+ *
+ * In order to turn this off in a release environment, it is recommended to additionally utilize R8
+ * rules which force a single value for the entire build artifact. This can result in the new code
+ * paths being completely removed from the artifact, which can often have nontrivial positive
+ * performance impact.
+ *
+ *      -assumevalues class androidx.compose.runtime.ComposeFoundationFlags {
+ *          public static int RemoveBasicTextGraphicsLayerEnabled return false
+ *      }
+ */
+@ExperimentalFoundationApi
+object ComposeFoundationFlags {
+
+    /**
+     * We have removed the implicit [graphicsLayer] from [BasicText]. This also affects the `Text`
+     * composable in material modules.
+     *
+     * This change ideally improves the initial rendering performance of [BasicText] but it may have
+     * negative effect on recomposition or redraw since [BasicText]s draw operations would not be
+     * cached in a separate layer.
+     */
+    @JvmField @Suppress("MutableBareField") var RemoveBasicTextGraphicsLayerEnabled: Boolean = true
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
index 5aacedf..34391a9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Scroll.kt
@@ -56,6 +56,7 @@
 import androidx.compose.ui.semantics.isTraversalGroup
 import androidx.compose.ui.semantics.verticalScrollAxisRange
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.util.fastCoerceIn
 import androidx.compose.ui.util.fastRoundToInt
 
 /**
@@ -364,7 +365,7 @@
         state.maxValue = side
         state.viewportSize = if (isVertical) height else width
         return layout(width, height) {
-            val scroll = state.value.coerceIn(0, side)
+            val scroll = state.value.fastCoerceIn(0, side)
             val absScroll = if (reverseScrolling) scroll - side else -scroll
             val xOffset = if (isVertical) 0 else absScroll
             val yOffset = if (isVertical) absScroll else 0
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/shape/CornerSize.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/shape/CornerSize.kt
index 768a7f0..bf80f49 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/shape/CornerSize.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/shape/CornerSize.kt
@@ -18,6 +18,7 @@
 
 import androidx.annotation.FloatRange
 import androidx.annotation.IntRange
+import androidx.compose.foundation.internal.throwIllegalArgumentException
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import androidx.compose.ui.geometry.Size
@@ -91,7 +92,7 @@
 ) : CornerSize, InspectableValue {
     init {
         if (percent < 0 || percent > 100) {
-            throw IllegalArgumentException("The percent should be in the range of [0, 100]")
+            throwIllegalArgumentException("The percent should be in the range of [0, 100]")
         }
     }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt
index e680d51..86db572e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/BasicText.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.foundation.text
 
+import androidx.compose.foundation.ComposeFoundationFlags
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.text.modifiers.SelectableTextAnnotatedStringElement
 import androidx.compose.foundation.text.modifiers.SelectionController
 import androidx.compose.foundation.text.modifiers.TextAnnotatedStringElement
@@ -113,8 +115,7 @@
     val finalModifier =
         if (selectionController != null || onTextLayout != null) {
             modifier
-                // TODO(b/274781644): Remove this graphicsLayer
-                .graphicsLayer()
+                .optionalGraphicsLayer()
                 .textModifier(
                     AnnotatedString(text = text),
                     style = style,
@@ -131,9 +132,7 @@
                     onShowTranslation = null
                 )
         } else {
-            modifier
-                // TODO(b/274781644): Remove this graphicsLayer
-                .graphicsLayer() then
+            modifier.optionalGraphicsLayer() then
                 TextStringSimpleElement(
                     text = text,
                     style = style,
@@ -208,8 +207,7 @@
         Layout(
             modifier =
                 modifier
-                    // TODO(b/274781644): Remove this graphicsLayer
-                    .graphicsLayer()
+                    .optionalGraphicsLayer()
                     .textModifier(
                         text = text,
                         style = style,
@@ -565,8 +563,7 @@
         },
         modifier =
             modifier
-                // TODO(b/274781644): Remove this graphicsLayer
-                .graphicsLayer()
+                .optionalGraphicsLayer()
                 .textModifier(
                     text = styledText(),
                     style = style,
@@ -598,3 +595,15 @@
             }
     )
 }
+
+/**
+ * Applies a full [graphicsLayer] modifier only if the associated flag
+ * [ComposeFoundationFlags.RemoveBasicTextGraphicsLayerEnabled] is disabled.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+private fun Modifier.optionalGraphicsLayer() =
+    if (ComposeFoundationFlags.RemoveBasicTextGraphicsLayerEnabled) {
+        this
+    } else {
+        this.graphicsLayer()
+    }
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/TestOnly.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/TestOnly.kt
index 0f57f58..fe01127 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/TestOnly.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/TestOnly.kt
@@ -17,7 +17,7 @@
 package androidx.compose.runtime
 
 @MustBeDocumented
-@Retention(AnnotationRetention.BINARY)
+@Retention(AnnotationRetention.SOURCE)
 @Target(
     AnnotationTarget.FUNCTION,
     AnnotationTarget.CONSTRUCTOR,
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.kt
index 34de83e..dac4652 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.kt
@@ -35,4 +35,6 @@
     companion object Key : CoroutineContext.Key<SnapshotContextElement>
 }
 
-internal expect class SnapshotContextElementImpl(snapshot: Snapshot) : SnapshotContextElement
+internal expect class SnapshotContextElementImpl(snapshot: Snapshot) : SnapshotContextElement {
+    override val key: CoroutineContext.Key<*>
+}
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt
index 31e55dd..a2125ff 100644
--- a/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt
+++ b/compose/runtime/runtime/src/commonTest/kotlin/kotlinx/test/IgnoreJsTarget.kt
@@ -15,4 +15,4 @@
  */
 package kotlinx.test
 
-expect annotation class IgnoreJsTarget
+expect annotation class IgnoreJsTarget()
diff --git a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.jvm.kt b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.jvm.kt
index d27116f..60e770a 100644
--- a/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.jvm.kt
+++ b/compose/runtime/runtime/src/jvmMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.jvm.kt
@@ -22,7 +22,7 @@
 internal actual class SnapshotContextElementImpl
 actual constructor(private val snapshot: Snapshot) :
     SnapshotContextElement, ThreadContextElement<Snapshot?> {
-    override val key: CoroutineContext.Key<*>
+    actual override val key: CoroutineContext.Key<*>
         get() = SnapshotContextElement
 
     override fun updateThreadContext(context: CoroutineContext): Snapshot? = snapshot.unsafeEnter()
diff --git a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/TestOnly.linuxx64Stubs.kt b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/TestOnly.linuxx64Stubs.kt
index 49d5da72..0a9efef 100644
--- a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/TestOnly.linuxx64Stubs.kt
+++ b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/TestOnly.linuxx64Stubs.kt
@@ -16,4 +16,12 @@
 
 package androidx.compose.runtime
 
+@MustBeDocumented
+@Retention(AnnotationRetention.SOURCE)
+@Target(
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.CONSTRUCTOR,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.PROPERTY_SETTER
+)
 actual annotation class TestOnly
diff --git a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.linuxx64Stubs.kt b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.linuxx64Stubs.kt
index 4763182..4ee02a1 100644
--- a/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.linuxx64Stubs.kt
+++ b/compose/runtime/runtime/src/linuxx64StubsMain/kotlin/androidx/compose/runtime/snapshots/SnapshotContextElement.linuxx64Stubs.kt
@@ -21,6 +21,6 @@
 
 internal actual class SnapshotContextElementImpl
 actual constructor(private val snapshot: Snapshot) : SnapshotContextElement {
-    override val key: CoroutineContext.Key<*>
+    actual override val key: CoroutineContext.Key<*>
         get() = implementedInJetBrainsFork()
 }
diff --git a/compose/ui/ui-geometry/api/current.txt b/compose/ui/ui-geometry/api/current.txt
index 7cfe3bd..3a2dac5 100644
--- a/compose/ui/ui-geometry/api/current.txt
+++ b/compose/ui/ui-geometry/api/current.txt
@@ -2,18 +2,23 @@
 package androidx.compose.ui.geometry {
 
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class CornerRadius {
+    ctor public CornerRadius(long packedValue);
     method @androidx.compose.runtime.Stable public inline operator float component1();
     method @androidx.compose.runtime.Stable public inline operator float component2();
     method public long copy(optional float x, optional float y);
     method @androidx.compose.runtime.Stable public operator long div(float operand);
-    method public float getX();
-    method public float getY();
+    method public long getPackedValue();
+    method public inline float getX();
+    method public inline float getY();
+    method @androidx.compose.runtime.Stable public inline boolean isCircular();
+    method @androidx.compose.runtime.Stable public inline boolean isZero();
     method @androidx.compose.runtime.Stable public operator long minus(long other);
     method @androidx.compose.runtime.Stable public operator long plus(long other);
     method @androidx.compose.runtime.Stable public operator long times(float operand);
-    method @androidx.compose.runtime.Stable public operator long unaryMinus();
-    property @androidx.compose.runtime.Stable public final float x;
-    property @androidx.compose.runtime.Stable public final float y;
+    method @androidx.compose.runtime.Stable public inline operator long unaryMinus();
+    property public final long packedValue;
+    property @androidx.compose.runtime.Stable public final inline float x;
+    property @androidx.compose.runtime.Stable public final inline float y;
     field public static final androidx.compose.ui.geometry.CornerRadius.Companion Companion;
   }
 
@@ -23,7 +28,7 @@
   }
 
   public final class CornerRadiusKt {
-    method @androidx.compose.runtime.Stable public static long CornerRadius(float x, optional float y);
+    method @androidx.compose.runtime.Stable public static inline long CornerRadius(float x, optional float y);
     method @androidx.compose.runtime.Stable public static long lerp(long start, long stop, float fraction);
   }
 
diff --git a/compose/ui/ui-geometry/api/restricted_current.txt b/compose/ui/ui-geometry/api/restricted_current.txt
index 940e4ba..13de29e 100644
--- a/compose/ui/ui-geometry/api/restricted_current.txt
+++ b/compose/ui/ui-geometry/api/restricted_current.txt
@@ -2,18 +2,23 @@
 package androidx.compose.ui.geometry {
 
   @androidx.compose.runtime.Immutable @kotlin.jvm.JvmInline public final value class CornerRadius {
+    ctor public CornerRadius(long packedValue);
     method @androidx.compose.runtime.Stable public inline operator float component1();
     method @androidx.compose.runtime.Stable public inline operator float component2();
     method public long copy(optional float x, optional float y);
     method @androidx.compose.runtime.Stable public operator long div(float operand);
-    method public float getX();
-    method public float getY();
+    method public long getPackedValue();
+    method public inline float getX();
+    method public inline float getY();
+    method @androidx.compose.runtime.Stable public inline boolean isCircular();
+    method @androidx.compose.runtime.Stable public inline boolean isZero();
     method @androidx.compose.runtime.Stable public operator long minus(long other);
     method @androidx.compose.runtime.Stable public operator long plus(long other);
     method @androidx.compose.runtime.Stable public operator long times(float operand);
-    method @androidx.compose.runtime.Stable public operator long unaryMinus();
-    property @androidx.compose.runtime.Stable public final float x;
-    property @androidx.compose.runtime.Stable public final float y;
+    method @androidx.compose.runtime.Stable public inline operator long unaryMinus();
+    property public final long packedValue;
+    property @androidx.compose.runtime.Stable public final inline float x;
+    property @androidx.compose.runtime.Stable public final inline float y;
     field public static final androidx.compose.ui.geometry.CornerRadius.Companion Companion;
   }
 
@@ -23,7 +28,7 @@
   }
 
   public final class CornerRadiusKt {
-    method @androidx.compose.runtime.Stable public static long CornerRadius(float x, optional float y);
+    method @androidx.compose.runtime.Stable public static inline long CornerRadius(float x, optional float y);
     method @androidx.compose.runtime.Stable public static long lerp(long start, long stop, float fraction);
   }
 
diff --git a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/CornerRadius.kt b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/CornerRadius.kt
index c584335..02343a2 100644
--- a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/CornerRadius.kt
+++ b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/CornerRadius.kt
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress")
+
 package androidx.compose.ui.geometry
 
 import androidx.compose.runtime.Immutable
@@ -28,7 +30,7 @@
  * and y axis respectively. By default the radius along the Y axis matches that of the given x-axis
  * unless otherwise specified. Negative radii values are clamped to 0.
  */
-@Stable fun CornerRadius(x: Float, y: Float = x) = CornerRadius(packFloats(x, y))
+@Stable inline fun CornerRadius(x: Float, y: Float = x) = CornerRadius(packFloats(x, y))
 
 /**
  * A radius for either circular or elliptical (oval) shapes.
@@ -39,36 +41,49 @@
  */
 @Immutable
 @kotlin.jvm.JvmInline
-value class CornerRadius internal constructor(@PublishedApi internal val packedValue: Long) {
-
+value class CornerRadius(val packedValue: Long) {
     /** The radius value on the horizontal axis. */
     @Stable
-    val x: Float
+    inline val x: Float
         get() = unpackFloat1(packedValue)
 
     /** The radius value on the vertical axis. */
     @Stable
-    val y: Float
+    inline val y: Float
         get() = unpackFloat2(packedValue)
 
-    @Suppress("NOTHING_TO_INLINE") @Stable inline operator fun component1(): Float = x
+    @Stable inline operator fun component1(): Float = x
 
-    @Suppress("NOTHING_TO_INLINE") @Stable inline operator fun component2(): Float = y
+    @Stable inline operator fun component2(): Float = y
 
     /**
      * Returns a copy of this Radius instance optionally overriding the radius parameter for the x
      * or y axis
      */
-    fun copy(x: Float = this.x, y: Float = this.y) = CornerRadius(x, y)
+    fun copy(x: Float = unpackFloat1(packedValue), y: Float = unpackFloat2(packedValue)) =
+        CornerRadius(packFloats(x, y))
 
     companion object {
-
         /**
          * A radius with [x] and [y] values set to zero.
          *
          * You can use [CornerRadius.Zero] with [RoundRect] to have right-angle corners.
          */
-        @Stable val Zero: CornerRadius = CornerRadius(0.0f)
+        @Stable val Zero: CornerRadius = CornerRadius(0x0L)
+    }
+
+    /** Whether this corner radius is 0 in x, y, or both. */
+    @Stable
+    inline fun isZero(): Boolean {
+        // account for +/- 0.0f
+        val v = packedValue and DualUnsignedFloatMask
+        return ((v - 0x00000001_00000001L) and v.inv() and 0x80000000_80000000UL.toLong()) != 0L
+    }
+
+    /** Whether this corner radius describes a quarter circle (x == y). */
+    @Stable
+    inline fun isCircular(): Boolean {
+        return (packedValue ushr 32) == (packedValue and 0xffff_ffffL)
     }
 
     /**
@@ -80,7 +95,7 @@
      * expressions. For example, negating a radius of one pixel and then adding the result to
      * another radius is equivalent to subtracting a radius of one pixel from the other.
      */
-    @Stable operator fun unaryMinus() = CornerRadius(-x, -y)
+    @Stable inline operator fun unaryMinus() = CornerRadius(packedValue xor DualFloatSignBit)
 
     /**
      * Binary subtraction operator.
@@ -89,7 +104,15 @@
      * right-hand-side operand's [x] and whose [y] value is the left-hand-side operand's [y] minus
      * the right-hand-side operand's [y].
      */
-    @Stable operator fun minus(other: CornerRadius) = CornerRadius(x - other.x, y - other.y)
+    @Stable
+    operator fun minus(other: CornerRadius): CornerRadius {
+        return CornerRadius(
+            packFloats(
+                unpackFloat1(packedValue) - unpackFloat1(other.packedValue),
+                unpackFloat2(packedValue) - unpackFloat2(other.packedValue)
+            )
+        )
+    }
 
     /**
      * Binary addition operator.
@@ -97,7 +120,15 @@
      * Returns a radius whose [x] value is the sum of the [x] values of the two operands, and whose
      * [y] value is the sum of the [y] values of the two operands.
      */
-    @Stable operator fun plus(other: CornerRadius) = CornerRadius(x + other.x, y + other.y)
+    @Stable
+    operator fun plus(other: CornerRadius): CornerRadius {
+        return CornerRadius(
+            packFloats(
+                unpackFloat1(packedValue) + unpackFloat1(other.packedValue),
+                unpackFloat2(packedValue) + unpackFloat2(other.packedValue)
+            )
+        )
+    }
 
     /**
      * Multiplication operator.
@@ -105,7 +136,11 @@
      * Returns a radius whose coordinates are the coordinates of the left-hand-side operand (a
      * radius) multiplied by the scalar right-hand-side operand (a Float).
      */
-    @Stable operator fun times(operand: Float) = CornerRadius(x * operand, y * operand)
+    @Stable
+    operator fun times(operand: Float) =
+        CornerRadius(
+            packFloats(unpackFloat1(packedValue) * operand, unpackFloat2(packedValue) * operand)
+        )
 
     /**
      * Division operator.
@@ -113,7 +148,11 @@
      * Returns a radius whose coordinates are the coordinates of the left-hand-side operand (a
      * radius) divided by the scalar right-hand-side operand (a Float).
      */
-    @Stable operator fun div(operand: Float) = CornerRadius(x / operand, y / operand)
+    @Stable
+    operator fun div(operand: Float) =
+        CornerRadius(
+            packFloats(unpackFloat1(packedValue) / operand, unpackFloat2(packedValue) / operand)
+        )
 
     override fun toString(): String {
         return if (x == y) {
@@ -139,5 +178,10 @@
  */
 @Stable
 fun lerp(start: CornerRadius, stop: CornerRadius, fraction: Float): CornerRadius {
-    return CornerRadius(lerp(start.x, stop.x, fraction), lerp(start.y, stop.y, fraction))
+    return CornerRadius(
+        packFloats(
+            lerp(unpackFloat1(start.packedValue), unpackFloat1(stop.packedValue), fraction),
+            lerp(unpackFloat2(start.packedValue), unpackFloat2(stop.packedValue), fraction)
+        )
+    )
 }
diff --git a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/RoundRect.kt b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/RoundRect.kt
index e2b7e68..04dc73f 100644
--- a/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/RoundRect.kt
+++ b/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/RoundRect.kt
@@ -346,20 +346,18 @@
 /** Whether this rounded rectangle is a simple rectangle with zero corner radii. */
 val RoundRect.isRect
     get(): Boolean =
-        (topLeftCornerRadius.x == 0.0f || topLeftCornerRadius.y == 0.0f) &&
-            (topRightCornerRadius.x == 0.0f || topRightCornerRadius.y == 0.0f) &&
-            (bottomLeftCornerRadius.x == 0.0f || bottomLeftCornerRadius.y == 0.0f) &&
-            (bottomRightCornerRadius.x == 0.0f || bottomRightCornerRadius.y == 0.0f)
+        topLeftCornerRadius.isZero() &&
+            topRightCornerRadius.isZero() &&
+            bottomLeftCornerRadius.isZero() &&
+            bottomRightCornerRadius.isZero()
 
 /** Whether this rounded rectangle has no side with a straight section. */
 val RoundRect.isEllipse
     get(): Boolean =
-        topLeftCornerRadius.x == topRightCornerRadius.x &&
-            topLeftCornerRadius.y == topRightCornerRadius.y &&
-            topRightCornerRadius.x == bottomRightCornerRadius.x &&
-            topRightCornerRadius.y == bottomRightCornerRadius.y &&
-            bottomRightCornerRadius.x == bottomLeftCornerRadius.x &&
-            bottomRightCornerRadius.y == bottomLeftCornerRadius.y &&
+        topLeftCornerRadius.isCircular() &&
+            topRightCornerRadius.isCircular() &&
+            bottomLeftCornerRadius.isCircular() &&
+            bottomRightCornerRadius.isCircular() &&
             width <= 2.0 * topLeftCornerRadius.x &&
             height <= 2.0 * topLeftCornerRadius.y
 
@@ -390,13 +388,10 @@
  */
 val RoundRect.isSimple: Boolean
     get() =
-        topLeftCornerRadius.x == topLeftCornerRadius.y &&
-            topLeftCornerRadius.x == topRightCornerRadius.x &&
-            topLeftCornerRadius.x == topRightCornerRadius.y &&
-            topLeftCornerRadius.x == bottomRightCornerRadius.x &&
-            topLeftCornerRadius.x == bottomRightCornerRadius.y &&
-            topLeftCornerRadius.x == bottomLeftCornerRadius.x &&
-            topLeftCornerRadius.x == bottomLeftCornerRadius.y
+        topLeftCornerRadius.isCircular() &&
+            topLeftCornerRadius.packedValue == topRightCornerRadius.packedValue &&
+            topLeftCornerRadius.packedValue == bottomRightCornerRadius.packedValue &&
+            topLeftCornerRadius.packedValue == bottomLeftCornerRadius.packedValue
 
 /**
  * Linearly interpolate between two rounded rectangles.
diff --git a/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt b/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
index 0fafc4ba..1718fa8 100644
--- a/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
+++ b/compose/ui/ui-inspection/src/androidTest/java/androidx/compose/ui/inspection/inspector/LayoutInspectorTreeTest.kt
@@ -222,7 +222,7 @@
             )
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
                 left = 0.dp,
                 top = 0.0.dp,
@@ -260,7 +260,7 @@
             )
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
                 left = 21.dp,
                 top = 53.dp,
@@ -348,10 +348,10 @@
         validate(nodes, builder) {
             node("ModalDrawer", isRenderNode = true, children = listOf("Column", "Text"))
             node("Column", inlined = true, children = listOf("Text", "Button"))
-            node("Text", isRenderNode = true)
+            node("Text", isRenderNode = false)
             node("Button", isRenderNode = true, children = listOf("Text"))
-            node("Text", isRenderNode = true)
-            node("Text", isRenderNode = true)
+            node("Text", isRenderNode = false)
+            node("Text", isRenderNode = false)
         }
         assertThat(nodes.size).isEqualTo(1)
     }
@@ -517,7 +517,7 @@
             node("Column", children = listOf("Text", "Row", "Row"), inlined = true)
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 mergedSemantics = "[Studio]",
                 unmergedSemantics = "[Studio]",
             )
@@ -527,8 +527,8 @@
                 mergedSemantics = "[Hello, World]",
                 inlined = true,
             )
-            node("Text", isRenderNode = true, unmergedSemantics = "[Hello]")
-            node("Text", isRenderNode = true, unmergedSemantics = "[World]")
+            node("Text", isRenderNode = false, unmergedSemantics = "[Hello]")
+            node("Text", isRenderNode = false, unmergedSemantics = "[World]")
             node(
                 name = "Row",
                 children = listOf("Text", "Text"),
@@ -536,8 +536,8 @@
                 unmergedSemantics = "[to]",
                 inlined = true,
             )
-            node("Text", isRenderNode = true, unmergedSemantics = "[Hello]")
-            node("Text", isRenderNode = true, unmergedSemantics = "[World]")
+            node("Text", isRenderNode = false, unmergedSemantics = "[Hello]")
+            node("Text", isRenderNode = false, unmergedSemantics = "[World]")
         }
     }
 
@@ -547,7 +547,7 @@
 
         show {
             Inspectable(slotTableRecord) {
-                Column {
+                Column(modifier = Modifier.fillMaxSize()) {
                     Text("Hello World!")
                     AlertDialog(
                         onDismissRequest = {},
@@ -576,10 +576,11 @@
                 fileName = "LayoutInspectorTreeTest.kt",
                 children = listOf("Text"),
                 inlined = true,
+                isRenderNode = true,
             )
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
             )
         }
@@ -603,7 +604,7 @@
             )
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
             )
         }
@@ -642,7 +643,7 @@
             )
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
             )
         }
@@ -656,7 +657,7 @@
             node(name = "Popup", fileName = "LayoutInspectorTreeTest.kt", children = listOf("Text"))
             node(
                 name = "Text",
-                isRenderNode = true,
+                isRenderNode = false,
                 fileName = "LayoutInspectorTreeTest.kt",
             )
         }
diff --git a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
index 9b8d345..06d6e36 100644
--- a/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
+++ b/compose/ui/ui-unit/src/commonMain/kotlin/androidx/compose/ui/unit/Constraints.kt
@@ -165,16 +165,10 @@
     val isZero: Boolean
         get() {
             val bitOffset = indexToBitOffset(focusIndex)
-
-            // No need to special case width == 0 -> Infinity, instead we let it go to -1
-            // and fail the test that follows
             val maxWidth = ((value shr 33).toInt() and widthMask(bitOffset)) - 1
-            if (maxWidth == 0) return true
-
-            // Same here
             val offset = minHeightOffsets(bitOffset) + 31
             val maxHeight = ((value shr offset).toInt() and heightMask(bitOffset)) - 1
-            return maxHeight == 0
+            return (maxWidth == 0) or (maxHeight == 0)
         }
 
     /**
@@ -189,16 +183,15 @@
         minHeight: Int = this.minHeight,
         maxHeight: Int = this.maxHeight
     ): Constraints {
-        requirePrecondition(minHeight >= 0 && minWidth >= 0) {
-            "minHeight($minHeight) and minWidth($minWidth) must be >= 0"
-        }
-        // if maxWidth == Infinity, the test passes
-        requirePrecondition(maxWidth >= minWidth) {
-            "maxWidth($maxWidth) must be >= minWidth($minWidth)"
-        }
-        // if maxHeight == Infinity, the test passes
-        requirePrecondition(maxHeight >= minHeight) {
-            "maxHeight($maxHeight) must be >= minHeight($minHeight)"
+        requirePrecondition(
+            maxWidth >= minWidth && maxHeight >= minHeight && minWidth >= 0 && minHeight >= 0
+        ) {
+            """
+                maxWidth must be >= than minWidth,
+                maxHeight must be >= than minHeight,
+                minWidth and minHeight must be >= 0
+            """
+                .trimIndent()
         }
         return createConstraints(minWidth, maxWidth, minHeight, maxHeight)
     }
@@ -223,16 +216,14 @@
         /** Creates constraints for fixed size in both dimensions. */
         @Stable
         fun fixed(width: Int, height: Int): Constraints {
-            requirePrecondition(width >= 0 && height >= 0) {
-                "width($width) and height($height) must be >= 0"
-            }
+            requirePrecondition((width >= 0) and (height >= 0)) { "width and height must be >= 0" }
             return createConstraints(width, width, height, height)
         }
 
         /** Creates constraints for fixed width and unspecified height. */
         @Stable
         fun fixedWidth(width: Int): Constraints {
-            requirePrecondition(width >= 0) { "width($width) must be >= 0" }
+            requirePrecondition(width >= 0) { "width must be >= 0" }
             return createConstraints(
                 minWidth = width,
                 maxWidth = width,
@@ -244,7 +235,7 @@
         /** Creates constraints for fixed height and unspecified width. */
         @Stable
         fun fixedHeight(height: Int): Constraints {
-            requirePrecondition(height >= 0) { "height($height) must be >= 0" }
+            requirePrecondition(height >= 0) { "height must be >= 0" }
             return createConstraints(
                 minWidth = 0,
                 maxWidth = Infinity,
@@ -359,7 +350,7 @@
  *
  * 16 bits assigned to width, 15 bits assigned to height.
  */
-private const val MinFocusWidth = 0x02
+private const val MinFocusWidth = 0x2
 
 /**
  * The bit distribution when the focus of the bits should be on the width, and a maximal number of
@@ -367,7 +358,7 @@
  *
  * 18 bits assigned to width, 13 bits assigned to height.
  */
-private const val MaxFocusWidth = 0x03
+private const val MaxFocusWidth = 0x3
 
 /**
  * The bit distribution when the focus of the bits should be on the height, but only a minimal
@@ -375,7 +366,7 @@
  *
  * 15 bits assigned to width, 16 bits assigned to height.
  */
-private const val MinFocusHeight = 0x01
+private const val MinFocusHeight = 0x1
 
 /**
  * The bit distribution when the focus of the bits should be on the height, and a a maximal number
@@ -383,50 +374,43 @@
  *
  * 13 bits assigned to width, 18 bits assigned to height.
  */
-private const val MaxFocusHeight = 0x00
+private const val MaxFocusHeight = 0x0
 
 /**
  * The mask to retrieve the focus ([MinFocusWidth], [MaxFocusWidth], [MinFocusHeight],
  * [MaxFocusHeight]).
  */
-private const val FocusMask = 0x03L
+private const val FocusMask = 0x3L
 
 /** The number of bits used for the focused dimension when there is minimal focus. */
 private const val MinFocusBits = 16
 private const val MaxAllowedForMinFocusBits = (1 shl (31 - MinFocusBits)) - 2
 
-/** The mask to use for the focused dimension when there is minimal focus. */
-private const val MinFocusMask = 0xFFFF // 64K (16 bits)
-
 /** The number of bits used for the non-focused dimension when there is minimal focus. */
 private const val MinNonFocusBits = 15
 private const val MaxAllowedForMinNonFocusBits = (1 shl (31 - MinNonFocusBits)) - 2
 
-/** The mask to use for the non-focused dimension when there is minimal focus. */
-private const val MinNonFocusMask = 0x7FFF // 32K (15 bits)
-
 /** The number of bits to use for the focused dimension when there is maximal focus. */
 private const val MaxFocusBits = 18
 private const val MaxAllowedForMaxFocusBits = (1 shl (31 - MaxFocusBits)) - 2
 
 /** The mask to use for the focused dimension when there is maximal focus. */
-private const val MaxFocusMask = 0x3FFFF // 256K (18 bits)
+private const val MaxFocusMask = 0x3FFFF // 256K-1 (18 bits)
 
 /** The number of bits to use for the non-focused dimension when there is maximal focus. */
 private const val MaxNonFocusBits = 13
 private const val MaxAllowedForMaxNonFocusBits = (1 shl (31 - MaxNonFocusBits)) - 2
 
-/** The mask to use for the non-focused dimension when there is maximal focus. */
-private const val MaxNonFocusMask = 0x1FFF // 8K (13 bits)
-
 // Wrap those throws in functions to avoid inlining the string building at the call sites
-private fun throwInvalidConstraintException(widthVal: Int, heightVal: Int) {
+// Keep internal for codegen
+internal fun throwInvalidConstraintException(widthVal: Int, heightVal: Int) {
     throw IllegalArgumentException(
         "Can't represent a width of $widthVal and height of $heightVal in Constraints"
     )
 }
 
-private fun throwInvalidSizeException(size: Int): Nothing {
+// Keep internal for codegen
+internal fun throwInvalidConstraintsSizeException(size: Int): Nothing {
     throw IllegalArgumentException("Can't represent a size of $size in Constraints")
 }
 
@@ -456,16 +440,12 @@
     var maxHeightValue = maxHeight + 1
     maxHeightValue = maxHeightValue and (maxHeightValue shr 31).inv()
 
-    val focus =
-        when (widthBits) {
-            MinNonFocusBits -> MinFocusHeight
-            MinFocusBits -> MinFocusWidth
-            MaxNonFocusBits -> MaxFocusHeight
-            MaxFocusBits -> MaxFocusWidth
-            else -> 0x00 // can't happen, widthBits is computed from bitsNeedForSizeUnchecked()
-        }
+    // widthBits can be one of 13, 15, 16, or 18
+    // by subtracting 13 we obtain a bit offset as expected by bitOffsetToIndex()
+    val bitOffset = widthBits - 13
+    val focus = bitOffsetToIndex(bitOffset)
 
-    val minHeightOffset = minHeightOffsets(indexToBitOffset(focus))
+    val minHeightOffset = minHeightOffsets(bitOffset)
     val maxHeightOffset = minHeightOffset + 31
 
     val value =
@@ -477,23 +457,36 @@
     return Constraints(value)
 }
 
-private fun bitsNeedForSizeUnchecked(size: Int): Int {
+internal fun bitsNeedForSizeUnchecked(size: Int): Int {
+    // We could look at the value of size itself, for instance by doing:
+    // when {
+    //     size < MaxNonFocusMask -> MaxNonFocusBits
+    //     ...
+    // }
+    // but the following solution saves a few instructions by avoiding
+    // multiple moves to load large constants
+    val bits = (size + 1).countLeadingZeroBits()
     return when {
-        size < MaxNonFocusMask -> MaxNonFocusBits
-        size < MinNonFocusMask -> MinNonFocusBits
-        size < MinFocusMask -> MinFocusBits
-        size < MaxFocusMask -> MaxFocusBits
+        bits >= 32 - MaxNonFocusBits -> MaxNonFocusBits
+        bits >= 32 - MinNonFocusBits -> MinNonFocusBits
+        bits >= 32 - MinFocusBits -> MinFocusBits
+        bits >= 32 - MaxFocusBits -> MaxFocusBits
         else -> 255
     }
 }
 
-private fun maxAllowedForSize(size: Int): Int {
+private inline fun maxAllowedForSize(size: Int): Int {
+    // See comment in bitsNeedForSizeUnchecked()
+    // Note: the return value in every case is `1 shl (31 - bits) - 2`
+    // However, computing the value instead of using constants uses more
+    // instructions, so not worth it
+    val bits = (size + 1).countLeadingZeroBits()
+    if (bits <= 13) throwInvalidConstraintsSizeException(size)
     return when {
-        size < MaxNonFocusMask -> MaxAllowedForMaxNonFocusBits
-        size < MinNonFocusMask -> MaxAllowedForMinNonFocusBits
-        size < MinFocusMask -> MaxAllowedForMinFocusBits
-        size < MaxFocusMask -> MaxAllowedForMaxFocusBits
-        else -> throwInvalidSizeException(size)
+        bits >= 32 - MaxNonFocusBits -> MaxAllowedForMaxNonFocusBits
+        bits >= 32 - MinNonFocusBits -> MaxAllowedForMinNonFocusBits
+        bits >= 32 - MinFocusBits -> MaxAllowedForMinFocusBits
+        else -> MaxAllowedForMaxFocusBits
     }
 }
 
@@ -509,14 +502,15 @@
     minHeight: Int = 0,
     maxHeight: Int = Infinity
 ): Constraints {
-    requirePrecondition(maxWidth >= minWidth) {
-        "maxWidth($maxWidth) must be >= than minWidth($minWidth)"
-    }
-    requirePrecondition(maxHeight >= minHeight) {
-        "maxHeight($maxHeight) must be >= than minHeight($minHeight)"
-    }
-    requirePrecondition(minWidth >= 0 && minHeight >= 0) {
-        "minWidth($minWidth) and minHeight($minHeight) must be >= 0"
+    requirePrecondition(
+        (maxWidth >= minWidth) and (maxHeight >= minHeight) and (minWidth >= 0) and (minHeight >= 0)
+    ) {
+        """
+            maxWidth must be >= than minWidth,
+            maxHeight must be >= than minHeight,
+            minWidth and minHeight must be >= 0
+        """
+            .trimIndent()
     }
     return createConstraints(minWidth, maxWidth, minHeight, maxHeight)
 }
@@ -530,13 +524,18 @@
  * maxWidth=10).constrain(minWidth=11, maxWidth=12) -> (minWidth=10, maxWidth=10) (minWidth=2,
  * maxWidth=10).constrain(minWidth=5, maxWidth=7) -> (minWidth=5, maxWidth=7)
  */
-fun Constraints.constrain(otherConstraints: Constraints) =
-    Constraints(
+fun Constraints.constrain(otherConstraints: Constraints): Constraints {
+    val minWidth = minWidth
+    val maxWidth = maxWidth
+    val minHeight = minHeight
+    val maxHeight = maxHeight
+    return Constraints(
         minWidth = otherConstraints.minWidth.fastCoerceIn(minWidth, maxWidth),
         maxWidth = otherConstraints.maxWidth.fastCoerceIn(minWidth, maxWidth),
         minHeight = otherConstraints.minHeight.fastCoerceIn(minHeight, maxHeight),
         maxHeight = otherConstraints.maxHeight.fastCoerceIn(minHeight, maxHeight)
     )
+}
 
 /** Takes a size and returns the closest size to it that satisfies the constraints. */
 @Stable
@@ -568,7 +567,7 @@
         addMaxWithMinimum(maxHeight, vertical)
     )
 
-private fun addMaxWithMinimum(max: Int, value: Int): Int {
+private inline fun addMaxWithMinimum(max: Int, value: Int): Int {
     return if (max == Infinity) {
         max
     } else {
@@ -601,9 +600,9 @@
 //
 // From this mapping we can build all the other mappings:
 //
-// index = 0 -> MaxNonFocusMask = 0x1fff (13 bits)
-// index = 1 -> MinNonFocusMask = 0x7fff (15 bits)
-// index = 2 -> MinFocusMask    = 0xffff (16 bits)
+// index = 0 -> MaxNonFocusMask = 0x1fff  (13 bits)
+// index = 1 -> MinNonFocusMask = 0x7fff  (15 bits)
+// index = 2 -> MinFocusMask    = 0xffff  (16 bits)
 // index = 3 -> MaxFocusMask    = 0x3ffff (18 bits)
 //
 // WidthMask = (1 shl (13 + (index and 0x1 shl 1) + ((index and 0x2 shr 1) * 3))) - 1
@@ -637,6 +636,9 @@
 private inline fun indexToBitOffset(index: Int) =
     (index and 0x1 shl 1) + ((index and 0x2 shr 1) * 3)
 
+/** Maps a bit offset (0, 2, 3, or 5) to an index. It's the inverse of indexToBitOffset() */
+private inline fun bitOffsetToIndex(bits: Int) = (bits shr 1) + (bits and 0x1)
+
 /**
  * Minimum Height shift offsets into Long value, indexed by FocusMask Max offsets are these + 31
  * Width offsets are always either 2 (min) or 33 (max)
diff --git a/core/core-telecom/api/current.txt b/core/core-telecom/api/current.txt
index 57b1ced..40a8a93 100644
--- a/core/core-telecom/api/current.txt
+++ b/core/core-telecom/api/current.txt
@@ -9,7 +9,6 @@
     method public int getDirection();
     method public CharSequence getDisplayName();
     method public androidx.core.telecom.CallEndpointCompat? getPreferredStartingCallEndpoint();
-    method public void setPreferredStartingCallEndpoint(androidx.core.telecom.CallEndpointCompat?);
     property public final android.net.Uri address;
     property public final int callCapabilities;
     property public final int callType;
diff --git a/core/core-telecom/api/restricted_current.txt b/core/core-telecom/api/restricted_current.txt
index 57b1ced..40a8a93 100644
--- a/core/core-telecom/api/restricted_current.txt
+++ b/core/core-telecom/api/restricted_current.txt
@@ -9,7 +9,6 @@
     method public int getDirection();
     method public CharSequence getDisplayName();
     method public androidx.core.telecom.CallEndpointCompat? getPreferredStartingCallEndpoint();
-    method public void setPreferredStartingCallEndpoint(androidx.core.telecom.CallEndpointCompat?);
     property public final android.net.Uri address;
     property public final int callCapabilities;
     property public final int callType;
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
index 79752752..e3f5841 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/CallingMainActivity.kt
@@ -25,9 +25,17 @@
 import android.widget.CheckBox
 import androidx.annotation.RequiresApi
 import androidx.core.telecom.CallAttributesCompat
+import androidx.core.telecom.CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
+import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_INCOMING
+import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_OUTGOING
 import androidx.core.telecom.CallEndpointCompat
 import androidx.core.telecom.CallsManager
 import androidx.core.telecom.extensions.RaiseHandState
+import androidx.core.telecom.test.Utilities.Companion.ALL_CALL_CAPABILITIES
+import androidx.core.telecom.test.Utilities.Companion.INCOMING_NAME
+import androidx.core.telecom.test.Utilities.Companion.INCOMING_URI
+import androidx.core.telecom.test.Utilities.Companion.OUTGOING_NAME
+import androidx.core.telecom.test.Utilities.Companion.OUTGOING_URI
 import androidx.core.telecom.util.ExperimentalAppActions
 import androidx.core.view.WindowCompat
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -98,7 +106,14 @@
         addOutgoingCallButton.setOnClickListener {
             mScope.launch {
                 addCallWithAttributes(
-                    Utilities.OUTGOING_CALL_ATTRIBUTES,
+                    CallAttributesCompat(
+                        OUTGOING_NAME,
+                        OUTGOING_URI,
+                        DIRECTION_OUTGOING,
+                        CALL_TYPE_VIDEO_CALL,
+                        ALL_CALL_CAPABILITIES,
+                        mPreCallEndpointAdapter.mSelectedCallEndpoint
+                    ),
                     participantCheckBox.isChecked,
                     raiseHandCheckBox.isChecked,
                     kickParticipantCheckBox.isChecked
@@ -110,7 +125,14 @@
         addIncomingCallButton.setOnClickListener {
             mScope.launch {
                 addCallWithAttributes(
-                    Utilities.INCOMING_CALL_ATTRIBUTES,
+                    CallAttributesCompat(
+                        INCOMING_NAME,
+                        INCOMING_URI,
+                        DIRECTION_INCOMING,
+                        CALL_TYPE_VIDEO_CALL,
+                        ALL_CALL_CAPABILITIES,
+                        mPreCallEndpointAdapter.mSelectedCallEndpoint
+                    ),
                     participantCheckBox.isChecked,
                     raiseHandCheckBox.isChecked,
                     kickParticipantCheckBox.isChecked
diff --git a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt
index ff86d93..71c64f8 100644
--- a/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt
+++ b/core/core-telecom/integration-tests/testapp/src/main/java/androidx/core/telecom/test/Utilities.kt
@@ -29,9 +29,6 @@
 import androidx.annotation.RequiresApi
 import androidx.core.app.ActivityCompat
 import androidx.core.telecom.CallAttributesCompat
-import androidx.core.telecom.CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL
-import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_INCOMING
-import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_OUTGOING
 import androidx.core.util.Preconditions
 
 @RequiresApi(34)
@@ -46,28 +43,10 @@
         // outgoing attributes constants
         const val OUTGOING_NAME = "Darth Maul"
         val OUTGOING_URI: Uri = Uri.parse("tel:6506958985")
-        // Define the minimal set of properties to start an outgoing call
-        var OUTGOING_CALL_ATTRIBUTES =
-            CallAttributesCompat(
-                OUTGOING_NAME,
-                OUTGOING_URI,
-                DIRECTION_OUTGOING,
-                CALL_TYPE_VIDEO_CALL,
-                ALL_CALL_CAPABILITIES
-            )
 
         // incoming attributes constants
         const val INCOMING_NAME = "Sundar Pichai"
         val INCOMING_URI: Uri = Uri.parse("tel:6506958985")
-        // Define all possible properties for CallAttributes
-        val INCOMING_CALL_ATTRIBUTES =
-            CallAttributesCompat(
-                INCOMING_NAME,
-                INCOMING_URI,
-                DIRECTION_INCOMING,
-                CALL_TYPE_VIDEO_CALL,
-                ALL_CALL_CAPABILITIES
-            )
 
         // Audio recording config constants
         private const val SAMPLE_RATE = 44100
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallsManagerTest.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallsManagerTest.kt
index 1f93cff..29084ab 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallsManagerTest.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/CallsManagerTest.kt
@@ -33,6 +33,9 @@
 import androidx.core.telecom.internal.utils.Utils
 import androidx.core.telecom.test.utils.BaseTelecomTest
 import androidx.core.telecom.test.utils.TestUtils
+import androidx.core.telecom.test.utils.TestUtils.ALL_CALL_CAPABILITIES
+import androidx.core.telecom.test.utils.TestUtils.OUTGOING_NAME
+import androidx.core.telecom.test.utils.TestUtils.TEST_PHONE_NUMBER_8985
 import androidx.core.telecom.util.ExperimentalAppActions
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
@@ -273,9 +276,15 @@
                 initialEndpoints.find { it.type == CallEndpointCompat.TYPE_EARPIECE }
             if (initialEndpoints.size > 1 && earpieceEndpoint != null) {
                 Log.i(TAG, "found 2 endpoints, including TYPE_EARPIECE")
-                TestUtils.OUTGOING_CALL_ATTRIBUTES.preferredStartingCallEndpoint = earpieceEndpoint
                 mCallsManager.addCall(
-                    TestUtils.OUTGOING_CALL_ATTRIBUTES,
+                    CallAttributesCompat(
+                        OUTGOING_NAME,
+                        TEST_PHONE_NUMBER_8985,
+                        CallAttributesCompat.DIRECTION_OUTGOING,
+                        CallAttributesCompat.CALL_TYPE_AUDIO_CALL,
+                        ALL_CALL_CAPABILITIES,
+                        earpieceEndpoint
+                    ),
                     TestUtils.mOnAnswerLambda,
                     TestUtils.mOnDisconnectLambda,
                     TestUtils.mOnSetActiveLambda,
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
index 630a3e9..387ddd4 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/E2EExtensionTests.kt
@@ -20,6 +20,7 @@
 import android.content.Intent
 import android.os.Build
 import android.os.Build.VERSION_CODES
+import android.util.Log
 import androidx.core.telecom.CallAttributesCompat
 import androidx.core.telecom.CallControlResult
 import androidx.core.telecom.InCallServiceCompat
@@ -41,6 +42,7 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
 import androidx.test.rule.ServiceTestRule
+import java.util.concurrent.atomic.AtomicInteger
 import junit.framework.TestCase.assertEquals
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.first
@@ -65,6 +67,7 @@
 @RunWith(Parameterized::class)
 class E2EExtensionTests(private val parameters: TestParameters) : BaseTelecomTest() {
     companion object {
+        private val LOG_TAG = E2EExtensionTests::class.simpleName
         private const val ICS_EXTENSION_UPDATE_TIMEOUT_MS = 1000L
         // Use the VOIP service that uses V2 APIs (VoipAppExtensionControl)
         private const val SERVICE_SOURCE_V2 = 1
@@ -171,6 +174,8 @@
 
     @get:Rule val voipAppServiceRule: ServiceTestRule = ServiceTestRule()
 
+    private val mRequestIdGenerator = AtomicInteger(0)
+
     data class TestParameters(val serviceSource: Int, val direction: Int) {
         override fun toString(): String {
             return "${directionToString(direction)}-${sourceToString(serviceSource)}"
@@ -209,10 +214,13 @@
     fun testVoipWithExtensionsAndInCallServiceWithout() = runBlocking {
         usingIcs { ics ->
             val voipAppControl = bindToVoipAppWithExtensions()
+            val callback = TestCallCallbackListener(this)
+            voipAppControl.setCallback(callback)
             // No Capability Exchange sequence occurs between VoIP app and ICS because ICS doesn't
             // support extensions
             createAndVerifyVoipCall(
                 voipAppControl,
+                callback,
                 listOf(CAPABILITY_PARTICIPANT_WITH_ACTIONS),
                 parameters.direction
             )
@@ -241,6 +249,7 @@
             voipAppControl.setCallback(callback)
             createAndVerifyVoipCall(
                 voipAppControl,
+                callback,
                 listOf(getParticipantCapability(emptySet())),
                 parameters.direction
             )
@@ -279,6 +288,57 @@
     }
 
     /**
+     * On some Android versions (U & V), setting up an extension quickly after the ICS receives the
+     * new call can cause the CAPABILITY_EXCHANGE event to drop internally in Telecom.
+     *
+     * Run 10 iterations of adding a new call + setting up extensions to test that we do not hit
+     * this condition.
+     */
+    @LargeTest
+    @Test(timeout = 10000)
+    fun testVoipAndIcsWithParticipantsRace() = runBlocking {
+        usingIcs { ics ->
+            val iterations = 10
+            val voipAppControl = bindToVoipAppWithExtensions()
+            val callback = TestCallCallbackListener(this)
+            voipAppControl.setCallback(callback)
+            val failedTries = ArrayList<Int>()
+            for (i in 1..iterations) {
+                Log.i(LOG_TAG, "testVoipAndIcsWithParticipantsStress: try#$i")
+                val requestId = mRequestIdGenerator.getAndIncrement()
+                // Only wait for call setup on ICS side to stress extensions setup
+                createVoipCallAsync(
+                    voipAppControl,
+                    requestId,
+                    listOf(getParticipantCapability(emptySet())),
+                    parameters.direction
+                )
+                var hasConnected = false
+                with(ics) {
+                    val call = TestUtils.waitOnInCallServiceToReachXCalls(ics, 1)!!
+                    connectExtensions(call) {
+                        val participants = CachedParticipants(this)
+                        onConnected {
+                            hasConnected = true
+                            if (!participants.extension.isSupported) {
+                                failedTries.add(i)
+                            }
+                            call.disconnect()
+                        }
+                    }
+                }
+                assertTrue("onConnected never received", hasConnected)
+                // Ensure the ICS mCalls list is updated with the newly removed call so we don't
+                // accidentally grab the stale call when starting the next round.
+                TestUtils.waitOnInCallServiceToReachXCalls(ics, 0)
+            }
+            if (failedTries.isNotEmpty()) {
+                fail("Failed to set up extensions on ${failedTries.size}/$iterations tries")
+            }
+        }
+    }
+
+    /**
      * Create a VOIP call with a participants extension and attach participant Call extensions.
      * Verify raised hands functionality works as expected
      */
@@ -292,6 +352,7 @@
             val voipCallId =
                 createAndVerifyVoipCall(
                     voipAppControl,
+                    callback,
                     listOf(
                         getParticipantCapability(setOf(ParticipantExtensionImpl.RAISE_HAND_ACTION))
                     ),
@@ -353,6 +414,7 @@
             val voipCallId =
                 createAndVerifyVoipCall(
                     voipAppControl,
+                    callback,
                     listOf(getLocalSilenceCapability(setOf())),
                     parameters.direction
                 )
@@ -397,6 +459,7 @@
             val voipCallId =
                 createAndVerifyVoipCall(
                     voipAppControl,
+                    callback,
                     listOf(
                         getParticipantCapability(
                             setOf(ParticipantExtensionImpl.KICK_PARTICIPANT_ACTION)
@@ -439,24 +502,35 @@
      * Helpers
      * =========================================================================================
      */
+    private fun createVoipCallAsync(
+        voipAppControl: ITestAppControl,
+        requestId: Int,
+        capabilities: List<Capability>,
+        direction: Int
+    ) {
+        // add a call to verify capability exchange IS made with ICS
+        voipAppControl.addCall(
+            requestId,
+            capabilities,
+            direction == CallAttributesCompat.DIRECTION_OUTGOING
+        )
+    }
 
     /**
      * Creates a VOIP call using the specified capabilities and direction and then verifies that it
      * was set up.
      */
-    private fun createAndVerifyVoipCall(
+    private suspend fun createAndVerifyVoipCall(
         voipAppControl: ITestAppControl,
+        callback: TestCallCallbackListener,
         capabilities: List<Capability>,
         direction: Int
     ): String {
-        // add a call to verify capability exchange IS made with ICS
-        val voipCallId =
-            voipAppControl.addCall(
-                capabilities,
-                direction == CallAttributesCompat.DIRECTION_OUTGOING
-            )
-        assertTrue("call could not be created", voipCallId.isNotEmpty())
-        return voipCallId
+        val requestId = mRequestIdGenerator.getAndIncrement()
+        createVoipCallAsync(voipAppControl, requestId, capabilities, direction)
+        val callId = callback.waitForCallAdded(requestId)
+        assertTrue("call could not be created", !callId.isNullOrEmpty())
+        return callId!!
     }
 
     /** Sets up the test based on the parameters set for the run */
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/VoipAppWithExtensions/VoipAppWithExtensionsControl.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/VoipAppWithExtensions/VoipAppWithExtensionsControl.kt
index aeea2a7..74aca2a 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/VoipAppWithExtensions/VoipAppWithExtensionsControl.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/VoipAppWithExtensions/VoipAppWithExtensionsControl.kt
@@ -37,10 +37,8 @@
 import androidx.core.telecom.test.utils.TestUtils
 import androidx.core.telecom.util.ExperimentalAppActions
 import kotlin.coroutines.cancellation.CancellationException
-import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.drop
@@ -49,7 +47,7 @@
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 
-@OptIn(ExperimentalCoroutinesApi::class, ExperimentalAppActions::class)
+@OptIn(ExperimentalAppActions::class)
 @RequiresApi(Build.VERSION_CODES.O)
 open class VoipAppWithExtensionsControl : Service() {
     var mCallsManager: CallsManager? = null
@@ -87,10 +85,13 @@
                 Log.i(TAG, "onDisconnect: disconnectCause=[$it]")
             }
 
-            override fun addCall(capabilities: List<Capability>, isOutgoing: Boolean): String {
-                var id = ""
+            override fun addCall(
+                requestId: Int,
+                capabilities: List<Capability>,
+                isOutgoing: Boolean
+            ) {
+                Log.i(TAG, "VoipAppWithExtensionsControl: addCall: request")
                 runBlocking {
-                    val deferredId = CompletableDeferred<String>()
                     val call = VoipCall(mCallsManager!!, mCallback, capabilities)
                     mScope?.launch {
                         with(call) {
@@ -132,14 +133,11 @@
                                         localCallSilenceUpdater?.updateIsLocallySilenced(it)
                                     }
                                     .launchIn(this)
-                                deferredId.complete(this.getCallId().toString())
+                                mCallback?.onCallAdded(requestId, this.getCallId().toString())
                             }
                         }
                     }
-                    deferredId.await()
-                    id = deferredId.getCompleted()
                 }
-                return id
             }
 
             override fun updateParticipants(setOfParticipants: List<ParticipantParcelable>) {
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestInCallService.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestInCallService.kt
index 6e23441..b9e6b5c 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestInCallService.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestInCallService.kt
@@ -24,7 +24,6 @@
 import android.util.Log
 import androidx.annotation.RequiresApi
 import androidx.core.telecom.InCallServiceCompat
-import androidx.core.telecom.util.ExperimentalAppActions
 import java.util.Collections
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.first
@@ -116,7 +115,6 @@
         mCalls.clear()
     }
 
-    @ExperimentalAppActions
     fun getLastCall(): Call? {
         return if (mCalls.size == 0) {
             null
diff --git a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
index 214a196..676c404 100644
--- a/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
+++ b/core/core-telecom/src/androidTest/java/androidx/core/telecom/test/utils/TestUtils.kt
@@ -47,6 +47,7 @@
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withTimeout
@@ -59,29 +60,30 @@
 object TestUtils {
     const val LOG_TAG = "TelecomTestUtils"
     const val TEST_PACKAGE = "androidx.core.telecom.test"
-    const val COMMAND_SET_DEFAULT_DIALER = "telecom set-default-dialer " // DO NOT REMOVE SPACE
-    const val COMMAND_GET_DEFAULT_DIALER = "telecom get-default-dialer"
-    const val COMMAND_ENABLE_PHONE_ACCOUNT = "telecom set-phone-account-enabled "
+    private const val COMMAND_SET_DEFAULT_DIALER =
+        "telecom set-default-dialer " // DO NOT REMOVE SPACE
+    private const val COMMAND_GET_DEFAULT_DIALER = "telecom get-default-dialer"
+    private const val COMMAND_ENABLE_PHONE_ACCOUNT = "telecom set-phone-account-enabled "
     const val COMMAND_CLEANUP_STUCK_CALLS = "telecom cleanup-stuck-calls"
     const val COMMAND_DUMP_TELECOM = "dumpsys telecom"
     const val TEST_CALL_ATTRIB_NAME = "Elon Musk"
     const val OUTGOING_NAME = "Larry Page"
-    const val INCOMING_NAME = "Sundar Pichai"
+    private const val INCOMING_NAME = "Sundar Pichai"
     const val WAIT_ON_ASSERTS_TO_FINISH_TIMEOUT = 10000L
     const val WAIT_ON_CALL_STATE_TIMEOUT = 8000L
-    const val WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT = 5000L
-    const val WAIT_ON_IN_CALL_SERVICE_CALL_COMPAT_COUNT_TIMEOUT = 5000L
+    private const val WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT = 5000L
     const val ALL_CALL_CAPABILITIES =
         (CallAttributesCompat.SUPPORTS_SET_INACTIVE or
             CallAttributesCompat.SUPPORTS_STREAM or
             CallAttributesCompat.SUPPORTS_TRANSFER)
-    val VERIFICATION_TIMEOUT_MSG =
+    const val VERIFICATION_TIMEOUT_MSG =
         "Timed out before asserting all values. This most likely means the platform failed to" +
             " add the call or hung on a CallControl operation."
-    val CALLBACK_FAILED_EXCEPTION_MSG = "callback failed to be completed in the lambda function"
+    private const val CALLBACK_FAILED_EXCEPTION_MSG =
+        "callback failed to be completed in the lambda function"
     // non-primitive constants
-    val TEST_PHONE_NUMBER_9001 = Uri.parse("tel:6506959001")
-    val TEST_PHONE_NUMBER_8985 = Uri.parse("tel:6506958985")
+    val TEST_PHONE_NUMBER_9001: Uri = Uri.parse("tel:6506959001")
+    val TEST_PHONE_NUMBER_8985: Uri = Uri.parse("tel:6506958985")
 
     // Define the minimal set of properties to start an outgoing call
     val OUTGOING_CALL_ATTRIBUTES =
@@ -298,78 +300,73 @@
         return ParcelUuid.fromString(UUID.randomUUID().toString())
     }
 
-    @OptIn(ExperimentalAppActions::class)
-    @Suppress("deprecation")
+    /**
+     * Suspends until the [targetCallCount] is reached, or times out after
+     * [WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT] milliseconds.
+     */
     internal suspend fun waitOnInCallServiceToReachXCalls(
         service: TestInCallService,
         targetCallCount: Int
     ): Call? {
-        var targetCall: Call?
-        try {
-            withTimeout(WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT) {
-                Log.i(LOG_TAG, "waitOnInCallServiceToReachXCalls: starting call check")
-                while (isActive && (service.getCallCount() < targetCallCount)) {
-                    yield() // ensure the coroutine is not canceled
-                    delay(1) // sleep x millisecond(s) instead of spamming check
-                }
-                targetCall = service.getLastCall()
-                Log.i(LOG_TAG, "waitOnInCallServiceToReachXCalls: found targetCall=[$targetCall]")
-            }
-        } catch (e: TimeoutCancellationException) {
-            Log.i(LOG_TAG, "waitOnInCallServiceToReachXCalls: timeout reached")
-            dumpTelecom()
-            service.destroyAllCalls()
-            throw AssertionError(
+        var targetCall: Call? = null
+        Log.i(
+            LOG_TAG,
+            "waitOnInCallServiceToReachXCalls: target count=$targetCallCount, " +
+                "starting call check"
+        )
+        if (targetCallCount > 0) {
+            waitForCondition(
+                WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT,
                 "Expected call count to be <$targetCallCount>" +
                     " but the Actual call count was <${service.getCallCount()}>"
-            )
+            ) {
+                service.getCallCount() >= targetCallCount
+            }
+            targetCall = service.getLastCall()
+            Log.i(LOG_TAG, "waitOnInCallServiceToReachXCalls: found targetCall=[$targetCall]")
+        } else {
+            waitForCondition(
+                WAIT_ON_IN_CALL_SERVICE_CALL_COUNT_TIMEOUT,
+                "Expected call count to be <$targetCallCount>" +
+                    " but the Actual call count was <${service.getCallCount()}>"
+            ) {
+                service.getCallCount() <= 0
+            }
+            Log.i(LOG_TAG, "waitOnInCallServiceToReachXCalls: reached 0 calls")
         }
         return targetCall
     }
 
-    @Suppress("deprecation")
+    @Suppress("DEPRECATION")
     suspend fun waitOnCallState(call: Call, targetState: Int) {
+        waitForCondition(
+            WAIT_ON_CALL_STATE_TIMEOUT,
+            "Expected call state to be <$targetState>" +
+                " but the Actual call state was <${call.state}>"
+        ) {
+            call.state == targetState
+        }
+    }
+
+    private suspend fun waitForCondition(
+        timeout: Long,
+        failureMessage: String,
+        expectedCondition: () -> Boolean
+    ) {
         try {
-            withTimeout(WAIT_ON_CALL_STATE_TIMEOUT) {
-                while (isActive /* aka  within timeout window */ && (call.state != targetState)) {
+            withTimeout(timeout) {
+                while (isActive /* aka  within timeout window */ && !expectedCondition()) {
                     yield() // another mechanism to stop the while loop if the coroutine is dead
                     delay(1) // sleep x millisecond(s) instead of spamming check
                 }
             }
         } catch (e: TimeoutCancellationException) {
-            Log.i(LOG_TAG, "waitOnCallState: timeout reached")
+            Log.i(LOG_TAG, "waitOnCondition: timeout reached")
             dumpTelecom()
-            throw AssertionError(
-                "Expected call state to be <$targetState>" +
-                    " but the Actual call state was <${call.state}>"
-            )
+            throw AssertionError(failureMessage)
         }
     }
 
-    /** Helper to wait on the call detail extras to be populated from the connection service */
-    suspend fun waitOnCallExtras(call: Call) {
-        try {
-            withTimeout(WAIT_ON_CALL_STATE_TIMEOUT) {
-                while (isActive /* aka  within timeout window */ && isCallDetailExtrasEmpty(call)) {
-                    yield() // another mechanism to stop the while loop if the coroutine is dead
-                    delay(1) // sleep x millisecond(s) instead of spamming check
-                }
-            }
-        } catch (e: TimeoutCancellationException) {
-            Log.i(LOG_TAG, "waitOnCallExtras: timeout reached")
-            dumpTelecom()
-            throw AssertionError("Expected call detail extras to be non-null.")
-        }
-    }
-
-    /**
-     * Helper used to determine if the call detail extras is empty or null, which is used as a basis
-     * for waiting in the voip app action tests (around capability exchange).
-     */
-    private fun isCallDetailExtrasEmpty(call: Call): Boolean {
-        return call.details?.extras == null || call.details.extras.isEmpty
-    }
-
     /**
      * Used for testing in V. The build version is not available for referencing so this helper
      * performs a manual check instead.
@@ -379,11 +376,6 @@
         return Build.VERSION.SDK_INT > 34
     }
 
-    /** Determine if the current build supports at least U. */
-    fun buildIsAtLeastU(): Boolean {
-        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
-    }
-
     /** Generate a List of [Participant]s, where each ID corresponds to a range of 1 to [num] */
     @ExperimentalAppActions
     fun generateParticipants(num: Int): List<Participant> {
@@ -421,6 +413,12 @@
         MutableSharedFlow(replay = 1)
     private val isLocallySilencedFlow: MutableSharedFlow<Pair<String, Boolean>> =
         MutableStateFlow(Pair("", false))
+    private val callAddedFlow: MutableSharedFlow<Pair<Int, String>> = MutableSharedFlow(replay = 1)
+
+    override fun onCallAdded(requestId: Int, callId: String?) {
+        if (callId == null) return
+        scope.launch { callAddedFlow.emit(Pair(requestId, callId)) }
+    }
 
     override fun raiseHandStateAction(callId: String?, isHandRaised: Boolean) {
         if (callId == null) return
@@ -437,6 +435,12 @@
         scope.launch { isLocallySilencedFlow.emit(Pair(callId, isLocallySilenced)) }
     }
 
+    suspend fun waitForCallAdded(requestId: Int): String? {
+        return withTimeoutOrNull(5000) {
+            callAddedFlow.filter { it.first == requestId }.map { it.second }.first()
+        }
+    }
+
     suspend fun waitForRaiseHandState(callId: String, expectedState: Boolean) {
         val result =
             withTimeoutOrNull(5000) {
diff --git a/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControl.aidl b/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControl.aidl
index 455ec11..3850248 100644
--- a/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControl.aidl
+++ b/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControl.aidl
@@ -7,9 +7,9 @@
 // NOTE: only supports one voip call at a time right now + suspend functions are not supported by
 // AIDL :(
 @JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
-interface ITestAppControl {
+oneway interface ITestAppControl {
   void setCallback(in ITestAppControlCallback callback);
-  String addCall(in List<Capability> capabilities, boolean isOutgoing);
+  void addCall(in int requestId, in List<Capability> capabilities, boolean isOutgoing);
   void updateParticipants(in List<ParticipantParcelable> participants);
   void updateActiveParticipant(in ParticipantParcelable participant);
   void updateRaisedHands(in List<ParticipantParcelable> raisedHandsParticipants);
diff --git a/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControlCallback.aidl b/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControlCallback.aidl
index 8e88d62..e508c83 100644
--- a/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControlCallback.aidl
+++ b/core/core-telecom/src/main/aidl/androidx/core/telecom/test/ITestAppControlCallback.aidl
@@ -4,6 +4,7 @@
 
 @JavaPassthrough(annotation="@androidx.annotation.RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY)")
 oneway interface ITestAppControlCallback {
+    void onCallAdded(int requestId, in String callId);
     void raiseHandStateAction(in String callId, boolean isHandRaised);
     void kickParticipantAction(in String callId, in ParticipantParcelable participant);
     void setLocalCallSilenceState(in String callId, boolean isLocallySilenced);
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/CallAttributesCompat.kt b/core/core-telecom/src/main/java/androidx/core/telecom/CallAttributesCompat.kt
index 865dcdd..54a6b0b 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/CallAttributesCompat.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/CallAttributesCompat.kt
@@ -46,7 +46,7 @@
     @Direction public val direction: Int,
     @CallType public val callType: Int = CALL_TYPE_AUDIO_CALL,
     @CallCapability public val callCapabilities: Int = SUPPORTS_SET_INACTIVE,
-    public var preferredStartingCallEndpoint: CallEndpointCompat? = null
+    public val preferredStartingCallEndpoint: CallEndpointCompat? = null
 ) {
     internal var mHandle: PhoneAccountHandle? = null
 
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt b/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
index 71ef177..e80acec 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/CallsManager.kt
@@ -118,6 +118,12 @@
             "android.telecom.extra.VOIP_BACKWARDS_COMPATIBILITY_SUPPORTED"
 
         /**
+         * Event sent from the call producer application to the external call surfaces to notify
+         * them that the call has been successfully setup and is ready to be used.
+         */
+        internal const val EVENT_CALL_READY = "androidx.core.telecom.EVENT_CALL_READY"
+
+        /**
          * The connection is using transactional call APIs.
          *
          * The underlying connection was added as a transactional call via the
@@ -355,11 +361,10 @@
      * callback flow will be continuously updated until the call session is established via
      * [addCall]. Once [addCall] is invoked with a
      * [CallAttributesCompat.preferredStartingCallEndpoint], the callback containing the
-     * [CallEndpointCompat] will be forced closed on behalf of the client. If the flow is canceled
-     * before adding the call, the [CallAttributesCompat.preferredStartingCallEndpoint] will be
-     * voided. If a call session isn't started, the flow should be cleaned up client-side by calling
-     * cancel() from the same [kotlinx.coroutines.CoroutineScope] the [callbackFlow] is collecting
-     * in.
+     * [CallEndpointCompat] will stop receiving updates. If the flow is canceled before adding the
+     * call, the [CallAttributesCompat.preferredStartingCallEndpoint] will be voided. If a call
+     * session isn't started, the flow should be cleaned up client-side by calling cancel() from the
+     * same [kotlinx.coroutines.CoroutineScope] the [callbackFlow] is collecting in.
      *
      * Note: The endpoints emitted will be sorted by the [CallEndpointCompat.type] . See
      * [CallEndpointCompat.compareTo] for the ordering. The first element in the list will be the
@@ -504,6 +509,7 @@
                     coroutineContext
                 )
 
+            callSession.sendEvent(EVENT_CALL_READY)
             callSession.maybeSwitchStartingEndpoint(callAttributes.preferredStartingCallEndpoint)
 
             // Run the clients code with the session active and exposed via the CallControlScope
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
index 39674ef..a58d5e2 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/extensions/CallExtensionScopeImpl.kt
@@ -104,6 +104,7 @@
 
         internal const val CAPABILITY_EXCHANGE_VERSION = 1
         internal const val RESOLVE_EXTENSIONS_TYPE_TIMEOUT_MS = 1000L
+        internal const val CALL_READY_TIMEOUT_MS = 500L
         internal const val CAPABILITY_EXCHANGE_TIMEOUT_MS = 1000L
 
         /** Constants used to denote the extension level supported by the VOIP app. */
@@ -328,6 +329,15 @@
      * does not support extensions at all.
      */
     private suspend fun performExchangeWithRemote(): CapabilityExchangeResult? {
+        if (Utils.hasPlatformV2Apis()) {
+            Log.d(TAG, "performExchangeWithRemote: waiting for call ready signal...")
+            withTimeoutOrNull(CALL_READY_TIMEOUT_MS) {
+                // On Android U/V, we must wait for the jetpack lib to send a call ready event to
+                // prevent a race between telecom setting the TransactionalServiceWrapper and
+                // sending the CAPABILITY_EXCHANGE event
+                waitForCallReady()
+            }
+        }
         Log.d(TAG, "performExchangeWithRemote: requesting extensions from remote")
         val extensions =
             withTimeoutOrNull(CAPABILITY_EXCHANGE_TIMEOUT_MS) { registerWithRemoteService() }
@@ -337,6 +347,21 @@
         return extensions
     }
 
+    /** Wait for the Call to receive [CallsManager.EVENT_CALL_READY] from the call producer. */
+    private suspend fun waitForCallReady() = suspendCancellableCoroutine { continuation ->
+        val callback =
+            object : Callback() {
+                override fun onConnectionEvent(call: Call?, event: String?, extras: Bundle?) {
+                    if (call == null || event == null) return
+                    if (event == CallsManager.EVENT_CALL_READY) {
+                        continuation.resume(Unit)
+                    }
+                }
+            }
+        call.registerCallback(callback, Handler(Looper.getMainLooper()))
+        continuation.invokeOnCancellation { call.unregisterCallback(callback) }
+    }
+
     /**
      * Initialize all extensions that were registered with [registerExtension] and provide the
      * negotiated capability or null if the remote doesn't support this extension.
diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
index e96ccbd..7808490 100644
--- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
+++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
@@ -376,6 +376,14 @@
         return result.getCompleted()
     }
 
+    fun sendEvent(event: String, extras: Bundle = Bundle.EMPTY) {
+        if (mPlatformInterface == null) {
+            Log.w(TAG, "sendEvent: platform interface is not set up, [$event] dropped")
+            return
+        }
+        mPlatformInterface!!.sendEvent(event, extras)
+    }
+
     suspend fun requestEndpointChange(endpoint: CallEndpointCompat): CallControlResult {
         val job: CompletableDeferred<CallControlResult> = CompletableDeferred()
         // cache the last CallEndpoint the user requested to reference in
diff --git a/fragment/fragment-compose/src/androidTest/java/androidx/fragment/compose/AndroidFragmentTest.kt b/fragment/fragment-compose/src/androidTest/java/androidx/fragment/compose/AndroidFragmentTest.kt
index a1369c7..9f70cc2 100644
--- a/fragment/fragment-compose/src/androidTest/java/androidx/fragment/compose/AndroidFragmentTest.kt
+++ b/fragment/fragment-compose/src/androidTest/java/androidx/fragment/compose/AndroidFragmentTest.kt
@@ -180,6 +180,31 @@
 
         onView(withText("Show me on Screen")).check(matches(isDisplayed()))
     }
+
+    @Test
+    fun recomposeWhenSwapFragmentClass() {
+
+        lateinit var clazz: MutableState<Class<out Fragment>>
+        testRule.setContent {
+            clazz = remember { mutableStateOf(FragmentForCompose::class.java) }
+            AndroidFragment(
+                clazz = clazz.value,
+                arguments = bundleOf("name" to clazz.value.simpleName)
+            )
+        }
+
+        testRule.waitForIdle()
+
+        onView(withText("My name is ${FragmentForCompose::class.simpleName}"))
+            .check(matches(isDisplayed()))
+
+        testRule.runOnIdle { clazz.value = FragmentForCompose2::class.java }
+
+        testRule.waitForIdle()
+
+        onView(withText("My name is ${FragmentForCompose2::class.simpleName}"))
+            .check(matches(isDisplayed()))
+    }
 }
 
 class FragmentForCompose : Fragment(R.layout.content) {
@@ -191,3 +216,13 @@
         }
     }
 }
+
+class FragmentForCompose2 : Fragment(R.layout.content) {
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        val name = arguments?.getString("name")
+        if (name != null) {
+            val textView = view.findViewById<TextView>(R.id.text)
+            textView.text = "My name is $name"
+        }
+    }
+}
diff --git a/fragment/fragment-compose/src/main/java/androidx/fragment/compose/AndroidFragment.kt b/fragment/fragment-compose/src/main/java/androidx/fragment/compose/AndroidFragment.kt
index 7d455e9..1d3a679 100644
--- a/fragment/fragment-compose/src/main/java/androidx/fragment/compose/AndroidFragment.kt
+++ b/fragment/fragment-compose/src/main/java/androidx/fragment/compose/AndroidFragment.kt
@@ -16,7 +16,9 @@
 
 package androidx.fragment.compose
 
+import android.content.Context
 import android.os.Bundle
+import android.view.View
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.currentCompositeKeyHash
@@ -84,20 +86,13 @@
     val view = LocalView.current
     val fragmentManager = remember(view) { FragmentManager.findFragmentManager(view) }
     val context = LocalContext.current
-    lateinit var container: FragmentContainerView
-    AndroidView(
-        {
-            container = FragmentContainerView(context)
-            container.id = hashKey
-            container
-        },
-        modifier
-    )
+    val containerFactory = remember { FragmentContainerViewFactory(hashKey) }
+    AndroidView(factory = containerFactory, modifier)
 
-    DisposableEffect(fragmentManager, clazz, fragmentState) {
+    DisposableEffect(fragmentManager, containerFactory, clazz, fragmentState) {
         var removeEvenIfStateIsSaved = false
         val fragment =
-            fragmentManager.findFragmentById(container.id)
+            fragmentManager.findFragmentById(containerFactory.container.id)
                 ?: fragmentManager.fragmentFactory
                     .instantiate(context.classLoader, clazz.name)
                     .apply {
@@ -107,7 +102,7 @@
                             fragmentManager
                                 .beginTransaction()
                                 .setReorderingAllowed(true)
-                                .add(container, this, "$hashKey")
+                                .add(containerFactory.container, this, "$hashKey")
                         if (fragmentManager.isStateSaved) {
                             // If the state is saved when we add the fragment,
                             // we want to remove the Fragment in onDispose
@@ -128,7 +123,7 @@
                             transaction.commitNow()
                         }
                     }
-        fragmentManager.onContainerAvailable(container)
+        fragmentManager.onContainerAvailable(containerFactory.container)
         @Suppress("UNCHECKED_CAST") updateCallback.value(fragment as T)
         onDispose {
             val state = fragmentManager.saveFragmentInstanceState(fragment)
@@ -146,3 +141,23 @@
         }
     }
 }
+
+private class FragmentContainerViewFactory(private val containerId: Int) : (Context) -> View {
+
+    // Backing field that stores the last created container
+    // that is assumed to be created always before it is access
+    // via the container property
+    private var lastCreatedContainer: FragmentContainerView? = null
+
+    val container: FragmentContainerView
+        get() =
+            checkNotNull(lastCreatedContainer) {
+                "AndroidView has not created a container for $containerId yet"
+            }
+
+    override operator fun invoke(context: Context) =
+        FragmentContainerView(context).also { container ->
+            container.id = containerId
+            lastCreatedContainer = container
+        }
+}
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt b/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
index b1923e8..642f4d3 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/DefaultSpecialEffectsController.kt
@@ -831,17 +831,39 @@
                         "Unable to start transition $mergedTransition for container $container."
                     }
                     seekCancelLambda = {
-                        if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
-                            Log.v(FragmentManager.TAG, "Animating to start")
-                        }
-                        transitionImpl.animateToStart(controller!!) {
-                            transitionInfos.forEach { transitionInfo ->
-                                val operation = transitionInfo.operation
-                                val view = operation.fragment.view
-                                if (view != null) {
-                                    operation.finalState.applyState(view, container)
+                        if (transitionInfos.all { it.operation.isSeeking }) {
+                            if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                                Log.v(FragmentManager.TAG, "Animating to start")
+                            }
+                            transitionImpl.animateToStart(controller!!) {
+                                transitionInfos.forEach { transitionInfo ->
+                                    val operation = transitionInfo.operation
+                                    val view = operation.fragment.view
+                                    if (view != null) {
+                                        operation.finalState.applyState(view, container)
+                                    }
                                 }
                             }
+                        } else {
+                            if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                                Log.v(FragmentManager.TAG, "Completing animating immediately")
+                            }
+                            @Suppress("DEPRECATION")
+                            val cancelSignal = androidx.core.os.CancellationSignal()
+                            transitionImpl.setListenerForTransitionEnd(
+                                transitionInfos[0].operation.fragment,
+                                mergedTransition,
+                                cancelSignal
+                            ) {
+                                if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
+                                    Log.v(
+                                        FragmentManager.TAG,
+                                        "Transition for all operations has completed"
+                                    )
+                                }
+                                transitionInfos.forEach { it.operation.completeEffect(this) }
+                            }
+                            cancelSignal.cancel()
                         }
                     }
                     if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt b/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
index 55efd95..47f8c64 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/SpecialEffectsController.kt
@@ -207,6 +207,12 @@
         synchronized(pendingOperations) {
             val currentlyRunningOperations = runningOperations.toMutableList()
             runningOperations.clear()
+            // If we have no pendingOperations, we should always cancel without seeking,
+            // otherwise, we should check if the fragment has mTransitioning set.
+            for (operation in currentlyRunningOperations) {
+                operation.isSeeking =
+                    pendingOperations.isNotEmpty() && operation.fragment.mTransitioning
+            }
             for (operation in currentlyRunningOperations) {
                 // Another operation is about to run while we already have operations running
                 // There are 2 cases that need to be handled:
@@ -232,12 +238,7 @@
                             "SpecialEffectsController: Cancelling operation $operation"
                         )
                     }
-                    // If we have no pendingOperations, we should always cancel without seeking,
-                    // otherwise, we should check if the fragment has mTransitioning set.
-                    operation.cancel(
-                        container,
-                        pendingOperations.isNotEmpty() && operation.fragment.mTransitioning
-                    )
+                    operation.cancel(container)
                 }
                 runningNonSeekableTransition = false
                 if (!operation.isComplete) {
@@ -339,6 +340,9 @@
             // First cancel running operations
             val runningOperations = runningOperations.toMutableList()
             for (operation in runningOperations) {
+                operation.isSeeking = false
+            }
+            for (operation in runningOperations) {
                 if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
                     val notAttachedMessage =
                         if (attachedToWindow) {
@@ -359,6 +363,9 @@
             // Then cancel pending operations
             val pendingOperations = pendingOperations.toMutableList()
             for (operation in pendingOperations) {
+                operation.isSeeking = false
+            }
+            for (operation in pendingOperations) {
                 if (FragmentManager.isLoggingEnabled(Log.VERBOSE)) {
                     val notAttachedMessage =
                         if (attachedToWindow) {
@@ -606,7 +613,7 @@
             private set
 
         var isSeeking = false
-            private set
+            internal set
 
         var isStarted = false
             private set
@@ -638,16 +645,6 @@
             }
         }
 
-        fun cancel(container: ViewGroup, withSeeking: Boolean) {
-            if (isCanceled) {
-                return
-            }
-            if (withSeeking) {
-                isSeeking = true
-            }
-            cancel(container)
-        }
-
         fun mergeWith(finalState: State, lifecycleImpact: LifecycleImpact) {
             when (lifecycleImpact) {
                 LifecycleImpact.ADDING ->
diff --git a/health/connect/connect-client/api/current.txt b/health/connect/connect-client/api/current.txt
index 131b06a..43c2df3 100644
--- a/health/connect/connect-client/api/current.txt
+++ b/health/connect/connect-client/api/current.txt
@@ -467,6 +467,61 @@
   public static final class ElevationGainedRecord.Companion {
   }
 
+  public abstract class ExerciseCompletionGoal {
+  }
+
+  public static final class ExerciseCompletionGoal.ActiveCaloriesBurnedGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.ActiveCaloriesBurnedGoal(androidx.health.connect.client.units.Energy activeCalories);
+    method public androidx.health.connect.client.units.Energy getActiveCalories();
+    property public final androidx.health.connect.client.units.Energy activeCalories;
+  }
+
+  public static final class ExerciseCompletionGoal.DistanceAndDurationGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DistanceAndDurationGoal(androidx.health.connect.client.units.Length distance, java.time.Duration duration);
+    method public androidx.health.connect.client.units.Length getDistance();
+    method public java.time.Duration getDuration();
+    property public final androidx.health.connect.client.units.Length distance;
+    property public final java.time.Duration duration;
+  }
+
+  public static final class ExerciseCompletionGoal.DistanceGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DistanceGoal(androidx.health.connect.client.units.Length distance);
+    method public androidx.health.connect.client.units.Length getDistance();
+    property public final androidx.health.connect.client.units.Length distance;
+  }
+
+  public static final class ExerciseCompletionGoal.DurationGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DurationGoal(java.time.Duration duration);
+    method public java.time.Duration getDuration();
+    property public final java.time.Duration duration;
+  }
+
+  public static final class ExerciseCompletionGoal.ManualCompletion extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    field public static final androidx.health.connect.client.records.ExerciseCompletionGoal.ManualCompletion INSTANCE;
+  }
+
+  public static final class ExerciseCompletionGoal.RepetitionsGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.RepetitionsGoal(java.time.Duration repetitions);
+    method public java.time.Duration getRepetitions();
+    property public final java.time.Duration repetitions;
+  }
+
+  public static final class ExerciseCompletionGoal.StepsGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.StepsGoal(int steps);
+    method public int getSteps();
+    property public final int steps;
+  }
+
+  public static final class ExerciseCompletionGoal.TotalCaloriesBurnedGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.TotalCaloriesBurnedGoal(androidx.health.connect.client.units.Energy totalCalories);
+    method public androidx.health.connect.client.units.Energy getTotalCalories();
+    property public final androidx.health.connect.client.units.Energy totalCalories;
+  }
+
+  public static final class ExerciseCompletionGoal.UnknownGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    field public static final androidx.health.connect.client.records.ExerciseCompletionGoal.UnknownGoal INSTANCE;
+  }
+
   public final class ExerciseLap {
     ctor public ExerciseLap(java.time.Instant startTime, java.time.Instant endTime, optional androidx.health.connect.client.units.Length? length);
     method public java.time.Instant getEndTime();
@@ -477,6 +532,61 @@
     property public final java.time.Instant startTime;
   }
 
+  public abstract class ExercisePerformanceTarget {
+  }
+
+  public static final class ExercisePerformanceTarget.AmrapTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    field public static final androidx.health.connect.client.records.ExercisePerformanceTarget.AmrapTarget INSTANCE;
+  }
+
+  public static final class ExercisePerformanceTarget.CadenceTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.CadenceTarget(double minCadence, double maxCadence);
+    method public double getMaxCadence();
+    method public double getMinCadence();
+    property public final double maxCadence;
+    property public final double minCadence;
+  }
+
+  public static final class ExercisePerformanceTarget.HeartRateTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.HeartRateTarget(double minHeartRate, double maxHeartRate);
+    method public double getMaxHeartRate();
+    method public double getMinHeartRate();
+    property public final double maxHeartRate;
+    property public final double minHeartRate;
+  }
+
+  public static final class ExercisePerformanceTarget.PowerTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.PowerTarget(androidx.health.connect.client.units.Power minPower, androidx.health.connect.client.units.Power maxPower);
+    method public androidx.health.connect.client.units.Power getMaxPower();
+    method public androidx.health.connect.client.units.Power getMinPower();
+    property public final androidx.health.connect.client.units.Power maxPower;
+    property public final androidx.health.connect.client.units.Power minPower;
+  }
+
+  public static final class ExercisePerformanceTarget.RateOfPerceivedExertionTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.RateOfPerceivedExertionTarget(int rpe);
+    method public int getRpe();
+    property public final int rpe;
+  }
+
+  public static final class ExercisePerformanceTarget.SpeedTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.SpeedTarget(androidx.health.connect.client.units.Velocity minSpeed, androidx.health.connect.client.units.Velocity maxSpeed);
+    method public androidx.health.connect.client.units.Velocity getMaxSpeed();
+    method public androidx.health.connect.client.units.Velocity getMinSpeed();
+    property public final androidx.health.connect.client.units.Velocity maxSpeed;
+    property public final androidx.health.connect.client.units.Velocity minSpeed;
+  }
+
+  public static final class ExercisePerformanceTarget.UnknownTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    field public static final androidx.health.connect.client.records.ExercisePerformanceTarget.UnknownTarget INSTANCE;
+  }
+
+  public static final class ExercisePerformanceTarget.WeightTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.WeightTarget(androidx.health.connect.client.units.Mass mass);
+    method public androidx.health.connect.client.units.Mass getMass();
+    property public final androidx.health.connect.client.units.Mass mass;
+  }
+
   public final class ExerciseRoute {
     ctor public ExerciseRoute(java.util.List<androidx.health.connect.client.records.ExerciseRoute.Location> route);
     method public java.util.List<androidx.health.connect.client.records.ExerciseRoute.Location> getRoute();
@@ -612,6 +722,7 @@
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments);
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps);
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps, optional androidx.health.connect.client.records.ExerciseRoute? exerciseRoute);
+    ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps, optional androidx.health.connect.client.records.ExerciseRoute? exerciseRoute, optional String? plannedExerciseSessionId);
     method public java.time.Instant getEndTime();
     method public java.time.ZoneOffset? getEndZoneOffset();
     method public androidx.health.connect.client.records.ExerciseRouteResult getExerciseRouteResult();
@@ -619,6 +730,7 @@
     method public java.util.List<androidx.health.connect.client.records.ExerciseLap> getLaps();
     method public androidx.health.connect.client.records.metadata.Metadata getMetadata();
     method public String? getNotes();
+    method public String? getPlannedExerciseSessionId();
     method public java.util.List<androidx.health.connect.client.records.ExerciseSegment> getSegments();
     method public java.time.Instant getStartTime();
     method public java.time.ZoneOffset? getStartZoneOffset();
@@ -630,6 +742,7 @@
     property public final java.util.List<androidx.health.connect.client.records.ExerciseLap> laps;
     property public androidx.health.connect.client.records.metadata.Metadata metadata;
     property public final String? notes;
+    property public final String? plannedExerciseSessionId;
     property public final java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments;
     property public java.time.Instant startTime;
     property public java.time.ZoneOffset? startZoneOffset;
@@ -1052,6 +1165,77 @@
     property public java.time.ZoneOffset? zoneOffset;
   }
 
+  public final class PlannedExerciseBlock {
+    ctor public PlannedExerciseBlock(int repetitions, java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> steps, optional String? description);
+    method public String? getDescription();
+    method public int getRepetitions();
+    method public java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> getSteps();
+    property public final String? description;
+    property public final int repetitions;
+    property public final java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> steps;
+  }
+
+  public final class PlannedExerciseSessionRecord implements androidx.health.connect.client.records.Record {
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata);
+    method public java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> getBlocks();
+    method public String? getCompletedExerciseSessionId();
+    method public java.time.Instant getEndTime();
+    method public java.time.ZoneOffset? getEndZoneOffset();
+    method public int getExerciseType();
+    method public androidx.health.connect.client.records.metadata.Metadata getMetadata();
+    method public String? getNotes();
+    method public java.time.Instant getStartTime();
+    method public java.time.ZoneOffset? getStartZoneOffset();
+    method public String? getTitle();
+    method public boolean hasExplicitTime();
+    property public final java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks;
+    property public final String? completedExerciseSessionId;
+    property public java.time.Instant endTime;
+    property public java.time.ZoneOffset? endZoneOffset;
+    property public final int exerciseType;
+    property public final boolean hasExplicitTime;
+    property public androidx.health.connect.client.records.metadata.Metadata metadata;
+    property public final String? notes;
+    property public java.time.Instant startTime;
+    property public java.time.ZoneOffset? startZoneOffset;
+    property public final String? title;
+    field public static final androidx.health.connect.client.records.PlannedExerciseSessionRecord.Companion Companion;
+  }
+
+  public static final class PlannedExerciseSessionRecord.Companion {
+  }
+
+  public final class PlannedExerciseStep {
+    ctor public PlannedExerciseStep(int exerciseType, int exercisePhase, androidx.health.connect.client.records.ExerciseCompletionGoal completionGoal, java.util.List<? extends androidx.health.connect.client.records.ExercisePerformanceTarget> performanceTargets, optional String? description);
+    method public androidx.health.connect.client.records.ExerciseCompletionGoal getCompletionGoal();
+    method public String? getDescription();
+    method public int getExercisePhase();
+    method public int getExerciseType();
+    method public java.util.List<androidx.health.connect.client.records.ExercisePerformanceTarget> getPerformanceTargets();
+    property public final androidx.health.connect.client.records.ExerciseCompletionGoal completionGoal;
+    property public final String? description;
+    property public final int exercisePhase;
+    property public final int exerciseType;
+    property public final java.util.List<androidx.health.connect.client.records.ExercisePerformanceTarget> performanceTargets;
+    field public static final androidx.health.connect.client.records.PlannedExerciseStep.Companion Companion;
+    field public static final int EXERCISE_PHASE_ACTIVE = 3; // 0x3
+    field public static final int EXERCISE_PHASE_COOLDOWN = 4; // 0x4
+    field public static final int EXERCISE_PHASE_RECOVERY = 5; // 0x5
+    field public static final int EXERCISE_PHASE_REST = 2; // 0x2
+    field public static final int EXERCISE_PHASE_UNKNOWN = 0; // 0x0
+    field public static final int EXERCISE_PHASE_WARMUP = 1; // 0x1
+  }
+
+  public static final class PlannedExerciseStep.Companion {
+  }
+
   public final class PowerRecord implements androidx.health.connect.client.records.Record {
     ctor public PowerRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PowerRecord.Sample> samples, optional androidx.health.connect.client.records.metadata.Metadata metadata);
     method public java.time.Instant getEndTime();
diff --git a/health/connect/connect-client/api/restricted_current.txt b/health/connect/connect-client/api/restricted_current.txt
index 501a83c..4eefc3f 100644
--- a/health/connect/connect-client/api/restricted_current.txt
+++ b/health/connect/connect-client/api/restricted_current.txt
@@ -467,6 +467,61 @@
   public static final class ElevationGainedRecord.Companion {
   }
 
+  public abstract class ExerciseCompletionGoal {
+  }
+
+  public static final class ExerciseCompletionGoal.ActiveCaloriesBurnedGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.ActiveCaloriesBurnedGoal(androidx.health.connect.client.units.Energy activeCalories);
+    method public androidx.health.connect.client.units.Energy getActiveCalories();
+    property public final androidx.health.connect.client.units.Energy activeCalories;
+  }
+
+  public static final class ExerciseCompletionGoal.DistanceAndDurationGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DistanceAndDurationGoal(androidx.health.connect.client.units.Length distance, java.time.Duration duration);
+    method public androidx.health.connect.client.units.Length getDistance();
+    method public java.time.Duration getDuration();
+    property public final androidx.health.connect.client.units.Length distance;
+    property public final java.time.Duration duration;
+  }
+
+  public static final class ExerciseCompletionGoal.DistanceGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DistanceGoal(androidx.health.connect.client.units.Length distance);
+    method public androidx.health.connect.client.units.Length getDistance();
+    property public final androidx.health.connect.client.units.Length distance;
+  }
+
+  public static final class ExerciseCompletionGoal.DurationGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.DurationGoal(java.time.Duration duration);
+    method public java.time.Duration getDuration();
+    property public final java.time.Duration duration;
+  }
+
+  public static final class ExerciseCompletionGoal.ManualCompletion extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    field public static final androidx.health.connect.client.records.ExerciseCompletionGoal.ManualCompletion INSTANCE;
+  }
+
+  public static final class ExerciseCompletionGoal.RepetitionsGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.RepetitionsGoal(java.time.Duration repetitions);
+    method public java.time.Duration getRepetitions();
+    property public final java.time.Duration repetitions;
+  }
+
+  public static final class ExerciseCompletionGoal.StepsGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.StepsGoal(int steps);
+    method public int getSteps();
+    property public final int steps;
+  }
+
+  public static final class ExerciseCompletionGoal.TotalCaloriesBurnedGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    ctor public ExerciseCompletionGoal.TotalCaloriesBurnedGoal(androidx.health.connect.client.units.Energy totalCalories);
+    method public androidx.health.connect.client.units.Energy getTotalCalories();
+    property public final androidx.health.connect.client.units.Energy totalCalories;
+  }
+
+  public static final class ExerciseCompletionGoal.UnknownGoal extends androidx.health.connect.client.records.ExerciseCompletionGoal {
+    field public static final androidx.health.connect.client.records.ExerciseCompletionGoal.UnknownGoal INSTANCE;
+  }
+
   public final class ExerciseLap {
     ctor public ExerciseLap(java.time.Instant startTime, java.time.Instant endTime, optional androidx.health.connect.client.units.Length? length);
     method public java.time.Instant getEndTime();
@@ -477,6 +532,61 @@
     property public final java.time.Instant startTime;
   }
 
+  public abstract class ExercisePerformanceTarget {
+  }
+
+  public static final class ExercisePerformanceTarget.AmrapTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    field public static final androidx.health.connect.client.records.ExercisePerformanceTarget.AmrapTarget INSTANCE;
+  }
+
+  public static final class ExercisePerformanceTarget.CadenceTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.CadenceTarget(double minCadence, double maxCadence);
+    method public double getMaxCadence();
+    method public double getMinCadence();
+    property public final double maxCadence;
+    property public final double minCadence;
+  }
+
+  public static final class ExercisePerformanceTarget.HeartRateTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.HeartRateTarget(double minHeartRate, double maxHeartRate);
+    method public double getMaxHeartRate();
+    method public double getMinHeartRate();
+    property public final double maxHeartRate;
+    property public final double minHeartRate;
+  }
+
+  public static final class ExercisePerformanceTarget.PowerTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.PowerTarget(androidx.health.connect.client.units.Power minPower, androidx.health.connect.client.units.Power maxPower);
+    method public androidx.health.connect.client.units.Power getMaxPower();
+    method public androidx.health.connect.client.units.Power getMinPower();
+    property public final androidx.health.connect.client.units.Power maxPower;
+    property public final androidx.health.connect.client.units.Power minPower;
+  }
+
+  public static final class ExercisePerformanceTarget.RateOfPerceivedExertionTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.RateOfPerceivedExertionTarget(int rpe);
+    method public int getRpe();
+    property public final int rpe;
+  }
+
+  public static final class ExercisePerformanceTarget.SpeedTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.SpeedTarget(androidx.health.connect.client.units.Velocity minSpeed, androidx.health.connect.client.units.Velocity maxSpeed);
+    method public androidx.health.connect.client.units.Velocity getMaxSpeed();
+    method public androidx.health.connect.client.units.Velocity getMinSpeed();
+    property public final androidx.health.connect.client.units.Velocity maxSpeed;
+    property public final androidx.health.connect.client.units.Velocity minSpeed;
+  }
+
+  public static final class ExercisePerformanceTarget.UnknownTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    field public static final androidx.health.connect.client.records.ExercisePerformanceTarget.UnknownTarget INSTANCE;
+  }
+
+  public static final class ExercisePerformanceTarget.WeightTarget extends androidx.health.connect.client.records.ExercisePerformanceTarget {
+    ctor public ExercisePerformanceTarget.WeightTarget(androidx.health.connect.client.units.Mass mass);
+    method public androidx.health.connect.client.units.Mass getMass();
+    property public final androidx.health.connect.client.units.Mass mass;
+  }
+
   public final class ExerciseRoute {
     ctor public ExerciseRoute(java.util.List<androidx.health.connect.client.records.ExerciseRoute.Location> route);
     method public java.util.List<androidx.health.connect.client.records.ExerciseRoute.Location> getRoute();
@@ -612,6 +722,7 @@
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments);
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps);
     ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps, optional androidx.health.connect.client.records.ExerciseRoute? exerciseRoute);
+    ctor public ExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata, optional java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments, optional java.util.List<androidx.health.connect.client.records.ExerciseLap> laps, optional androidx.health.connect.client.records.ExerciseRoute? exerciseRoute, optional String? plannedExerciseSessionId);
     method public java.time.Instant getEndTime();
     method public java.time.ZoneOffset? getEndZoneOffset();
     method public androidx.health.connect.client.records.ExerciseRouteResult getExerciseRouteResult();
@@ -619,6 +730,7 @@
     method public java.util.List<androidx.health.connect.client.records.ExerciseLap> getLaps();
     method public androidx.health.connect.client.records.metadata.Metadata getMetadata();
     method public String? getNotes();
+    method public String? getPlannedExerciseSessionId();
     method public java.util.List<androidx.health.connect.client.records.ExerciseSegment> getSegments();
     method public java.time.Instant getStartTime();
     method public java.time.ZoneOffset? getStartZoneOffset();
@@ -630,6 +742,7 @@
     property public final java.util.List<androidx.health.connect.client.records.ExerciseLap> laps;
     property public androidx.health.connect.client.records.metadata.Metadata metadata;
     property public final String? notes;
+    property public final String? plannedExerciseSessionId;
     property public final java.util.List<androidx.health.connect.client.records.ExerciseSegment> segments;
     property public java.time.Instant startTime;
     property public java.time.ZoneOffset? startZoneOffset;
@@ -1070,6 +1183,77 @@
     property public java.time.ZoneOffset? zoneOffset;
   }
 
+  public final class PlannedExerciseBlock {
+    ctor public PlannedExerciseBlock(int repetitions, java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> steps, optional String? description);
+    method public String? getDescription();
+    method public int getRepetitions();
+    method public java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> getSteps();
+    property public final String? description;
+    property public final int repetitions;
+    property public final java.util.List<androidx.health.connect.client.records.PlannedExerciseStep> steps;
+  }
+
+  public final class PlannedExerciseSessionRecord implements androidx.health.connect.client.records.IntervalRecord {
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes);
+    ctor public PlannedExerciseSessionRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes);
+    ctor public PlannedExerciseSessionRecord(java.time.LocalDate startDate, java.time.Duration duration, java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks, int exerciseType, optional String? title, optional String? notes, optional androidx.health.connect.client.records.metadata.Metadata metadata);
+    method public java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> getBlocks();
+    method public String? getCompletedExerciseSessionId();
+    method public java.time.Instant getEndTime();
+    method public java.time.ZoneOffset? getEndZoneOffset();
+    method public int getExerciseType();
+    method public androidx.health.connect.client.records.metadata.Metadata getMetadata();
+    method public String? getNotes();
+    method public java.time.Instant getStartTime();
+    method public java.time.ZoneOffset? getStartZoneOffset();
+    method public String? getTitle();
+    method public boolean hasExplicitTime();
+    property public final java.util.List<androidx.health.connect.client.records.PlannedExerciseBlock> blocks;
+    property public final String? completedExerciseSessionId;
+    property public java.time.Instant endTime;
+    property public java.time.ZoneOffset? endZoneOffset;
+    property public final int exerciseType;
+    property public final boolean hasExplicitTime;
+    property public androidx.health.connect.client.records.metadata.Metadata metadata;
+    property public final String? notes;
+    property public java.time.Instant startTime;
+    property public java.time.ZoneOffset? startZoneOffset;
+    property public final String? title;
+    field public static final androidx.health.connect.client.records.PlannedExerciseSessionRecord.Companion Companion;
+  }
+
+  public static final class PlannedExerciseSessionRecord.Companion {
+  }
+
+  public final class PlannedExerciseStep {
+    ctor public PlannedExerciseStep(int exerciseType, int exercisePhase, androidx.health.connect.client.records.ExerciseCompletionGoal completionGoal, java.util.List<? extends androidx.health.connect.client.records.ExercisePerformanceTarget> performanceTargets, optional String? description);
+    method public androidx.health.connect.client.records.ExerciseCompletionGoal getCompletionGoal();
+    method public String? getDescription();
+    method public int getExercisePhase();
+    method public int getExerciseType();
+    method public java.util.List<androidx.health.connect.client.records.ExercisePerformanceTarget> getPerformanceTargets();
+    property public final androidx.health.connect.client.records.ExerciseCompletionGoal completionGoal;
+    property public final String? description;
+    property public final int exercisePhase;
+    property public final int exerciseType;
+    property public final java.util.List<androidx.health.connect.client.records.ExercisePerformanceTarget> performanceTargets;
+    field public static final androidx.health.connect.client.records.PlannedExerciseStep.Companion Companion;
+    field public static final int EXERCISE_PHASE_ACTIVE = 3; // 0x3
+    field public static final int EXERCISE_PHASE_COOLDOWN = 4; // 0x4
+    field public static final int EXERCISE_PHASE_RECOVERY = 5; // 0x5
+    field public static final int EXERCISE_PHASE_REST = 2; // 0x2
+    field public static final int EXERCISE_PHASE_UNKNOWN = 0; // 0x0
+    field public static final int EXERCISE_PHASE_WARMUP = 1; // 0x1
+  }
+
+  public static final class PlannedExerciseStep.Companion {
+  }
+
   public final class PowerRecord implements androidx.health.connect.client.records.SeriesRecord<androidx.health.connect.client.records.PowerRecord.Sample> {
     ctor public PowerRecord(java.time.Instant startTime, java.time.ZoneOffset? startZoneOffset, java.time.Instant endTime, java.time.ZoneOffset? endZoneOffset, java.util.List<androidx.health.connect.client.records.PowerRecord.Sample> samples, optional androidx.health.connect.client.records.metadata.Metadata metadata);
     method public java.time.Instant getEndTime();
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/permission/HealthPermission.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/permission/HealthPermission.kt
index d277f62f..1394ec9 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/permission/HealthPermission.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/permission/HealthPermission.kt
@@ -43,6 +43,7 @@
 import androidx.health.connect.client.records.NutritionRecord
 import androidx.health.connect.client.records.OvulationTestRecord
 import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.PlannedExerciseSessionRecord
 import androidx.health.connect.client.records.PowerRecord
 import androidx.health.connect.client.records.Record
 import androidx.health.connect.client.records.RespiratoryRateRecord
@@ -174,6 +175,7 @@
             PERMISSION_PREFIX + "READ_TOTAL_CALORIES_BURNED"
         internal const val READ_VO2_MAX = PERMISSION_PREFIX + "READ_VO2_MAX"
         internal const val READ_WHEELCHAIR_PUSHES = PERMISSION_PREFIX + "READ_WHEELCHAIR_PUSHES"
+        internal const val READ_PLANNED_EXERCISE = PERMISSION_PREFIX + "READ_PLANNED_EXERCISE"
         internal const val READ_POWER = PERMISSION_PREFIX + "READ_POWER"
         internal const val READ_SPEED = PERMISSION_PREFIX + "READ_SPEED"
 
@@ -232,6 +234,7 @@
             PERMISSION_PREFIX + "WRITE_TOTAL_CALORIES_BURNED"
         internal const val WRITE_VO2_MAX = PERMISSION_PREFIX + "WRITE_VO2_MAX"
         internal const val WRITE_WHEELCHAIR_PUSHES = PERMISSION_PREFIX + "WRITE_WHEELCHAIR_PUSHES"
+        internal const val WRITE_PLANNED_EXERCISE = PERMISSION_PREFIX + "WRITE_PLANNED_EXERCISE"
         internal const val WRITE_POWER = PERMISSION_PREFIX + "WRITE_POWER"
         internal const val WRITE_SPEED = PERMISSION_PREFIX + "WRITE_SPEED"
 
@@ -328,6 +331,8 @@
                     READ_OVULATION_TEST.substringAfter(READ_PERMISSION_PREFIX),
                 OxygenSaturationRecord::class to
                     READ_OXYGEN_SATURATION.substringAfter(READ_PERMISSION_PREFIX),
+                PlannedExerciseSessionRecord::class to
+                    READ_PLANNED_EXERCISE.substringAfter(READ_PERMISSION_PREFIX),
                 PowerRecord::class to READ_POWER.substringAfter(READ_PERMISSION_PREFIX),
                 RespiratoryRateRecord::class to
                     READ_RESPIRATORY_RATE.substringAfter(READ_PERMISSION_PREFIX),
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseCompletionGoal.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseCompletionGoal.kt
new file mode 100644
index 0000000..f369b20
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseCompletionGoal.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.units.Energy
+import androidx.health.connect.client.units.Length
+import java.time.Duration
+
+/** A goal which should be met to complete a [PlannedExerciseStep]. */
+abstract class ExerciseCompletionGoal internal constructor() {
+    /** An [ExerciseCompletionGoal] that requires covering a specified distance. */
+    class DistanceGoal(
+        val distance: Length,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is DistanceGoal) return false
+
+            return distance == other.distance
+        }
+
+        override fun hashCode(): Int {
+            return distance.hashCode()
+        }
+
+        override fun toString(): String {
+            return "DistanceGoal(distance=$distance)"
+        }
+    }
+
+    /**
+     * An [ExerciseCompletionGoal] that requires covering a specified distance. Additionally, the
+     * step is not complete until the specified time has elapsed. Time remaining after the specified
+     * distance has been completed should be spent resting. In the context of swimming, this is
+     * sometimes referred to as 'interval training'.
+     *
+     * <p>For example, a swimming coach may specify '100m @ 1min40s'. This implies: complete 100m
+     * and if you manage it in 1min30s, you will have 10s of rest prior to the next set.
+     */
+    class DistanceAndDurationGoal(
+        val distance: Length,
+        val duration: Duration,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is DistanceAndDurationGoal) return false
+
+            return distance == other.distance && duration == other.duration
+        }
+
+        override fun toString(): String {
+            return "DistanceAndDurationGoal(distance=$distance, duration=$duration)"
+        }
+
+        override fun hashCode(): Int {
+            var result = distance.hashCode()
+            result = 31 * result + duration.hashCode()
+            return result
+        }
+    }
+
+    /** An [ExerciseCompletionGoal] that requires completing a specified number of steps. */
+    class StepsGoal(
+        val steps: Int,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is StepsGoal) return false
+
+            return steps == other.steps
+        }
+
+        override fun hashCode(): Int {
+            return steps
+        }
+
+        override fun toString(): String {
+            return "StepsGoal(steps=$steps)"
+        }
+    }
+
+    /** An [ExerciseCompletionGoal] that requires a specified duration to elapse. */
+    class DurationGoal(
+        val duration: Duration,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is DurationGoal) return false
+
+            return duration == other.duration
+        }
+
+        override fun hashCode(): Int {
+            return duration.hashCode()
+        }
+
+        override fun toString(): String {
+            return "DurationGoal(duration=$duration)"
+        }
+    }
+
+    /**
+     * An [ExerciseCompletionGoal] that requires a specified number of repetitions to be completed.
+     */
+    class RepetitionsGoal(
+        val repetitions: Duration,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is RepetitionsGoal) return false
+
+            return repetitions == other.repetitions
+        }
+
+        override fun hashCode(): Int {
+            return repetitions.hashCode()
+        }
+
+        override fun toString(): String {
+            return "RepetitionsGoal(repetitions=$repetitions)"
+        }
+    }
+
+    /**
+     * An [ExerciseCompletionGoal] that requires a specified number of total calories to be burned.
+     */
+    class TotalCaloriesBurnedGoal(
+        val totalCalories: Energy,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is TotalCaloriesBurnedGoal) return false
+
+            return totalCalories == other.totalCalories
+        }
+
+        override fun hashCode(): Int {
+            return totalCalories.hashCode()
+        }
+
+        override fun toString(): String {
+            return "TotalCaloriesBurnedGoal(totalCalories=$totalCalories)"
+        }
+    }
+
+    /**
+     * An [ExerciseCompletionGoal] that requires a specified number of active calories to be burned.
+     */
+    class ActiveCaloriesBurnedGoal(
+        val activeCalories: Energy,
+    ) : ExerciseCompletionGoal() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is ActiveCaloriesBurnedGoal) return false
+
+            return activeCalories == other.activeCalories
+        }
+
+        override fun hashCode(): Int {
+            return activeCalories.hashCode()
+        }
+
+        override fun toString(): String {
+            return "ActiveCaloriesBurnedGoal(activeCalories=$activeCalories)"
+        }
+    }
+
+    /** An [ExerciseCompletionGoal] that is unknown. */
+    object UnknownGoal : ExerciseCompletionGoal() {
+        override fun toString(): String {
+            return "UnknownGoal()"
+        }
+    }
+
+    /**
+     * An [ExerciseCompletionGoal] that has no specific target metric. It is up to the user to
+     * determine when the associated [PlannedExerciseStep] is complete, typically based upon some
+     * instruction in the [PlannedExerciseStep.description] field.
+     */
+    object ManualCompletion : ExerciseCompletionGoal() {
+        override fun toString(): String {
+            return "ManualCompletion()"
+        }
+    }
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExercisePerformanceTarget.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExercisePerformanceTarget.kt
new file mode 100644
index 0000000..35cb1ff
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExercisePerformanceTarget.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.units.Mass
+import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Velocity
+
+/** An ongoing target that should be met during a [PlannedExerciseStep]. */
+abstract class ExercisePerformanceTarget internal constructor() {
+    /**
+     * An [ExercisePerformanceTarget] that requires a target power range to be met during the
+     * associated [PlannedExerciseStep].
+     */
+    class PowerTarget(
+        val minPower: Power,
+        val maxPower: Power,
+    ) : ExercisePerformanceTarget() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is PowerTarget) return false
+
+            if (minPower != other.minPower) return false
+            if (maxPower != other.maxPower) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = minPower.hashCode()
+            result = 31 * result + maxPower.hashCode()
+            return result
+        }
+
+        override fun toString(): String {
+            return "PowerTarget(minPower=$minPower, maxPower=$maxPower)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires a target speed range to be met during the
+     * associated [PlannedExerciseStep].
+     */
+    class SpeedTarget(
+        val minSpeed: Velocity,
+        val maxSpeed: Velocity,
+    ) : ExercisePerformanceTarget() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is SpeedTarget) return false
+
+            if (minSpeed != other.minSpeed) return false
+            if (maxSpeed != other.maxSpeed) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = minSpeed.hashCode()
+            result = 31 * result + maxSpeed.hashCode()
+            return result
+        }
+
+        override fun toString(): String {
+            return "SpeedTarget(minSpeed=$minSpeed, maxSpeed=$maxSpeed)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires a target cadence range to be met during the
+     * associated [PlannedExerciseStep].The value may be interpreted as RPM for e.g. cycling
+     * activities, or as steps per minute for e.g. walking/running activities.
+     */
+    class CadenceTarget(
+        val minCadence: Double,
+        val maxCadence: Double,
+    ) : ExercisePerformanceTarget() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is CadenceTarget) return false
+
+            if (minCadence != other.minCadence) return false
+            if (maxCadence != other.maxCadence) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = minCadence.hashCode()
+            result = 31 * result + maxCadence.hashCode()
+            return result
+        }
+
+        override fun toString(): String {
+            return "CadenceTarget(minCadence=$minCadence, maxCadence=$maxCadence)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires a target heart rate range, in BPM, to be met
+     * during the associated {@link PlannedExerciseStep}.
+     */
+    class HeartRateTarget(
+        val minHeartRate: Double,
+        val maxHeartRate: Double,
+    ) : ExercisePerformanceTarget() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is HeartRateTarget) return false
+
+            if (minHeartRate != other.minHeartRate) return false
+            if (maxHeartRate != other.maxHeartRate) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int {
+            var result = minHeartRate.hashCode()
+            result = 31 * result + maxHeartRate.hashCode()
+            return result
+        }
+
+        override fun toString(): String {
+            return "HeartRateTarget(minHeartRate=$minHeartRate, maxHeartRate=$maxHeartRate)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires a target weight to be lifted during the
+     * associated [PlannedExerciseStep].
+     */
+    class WeightTarget(
+        val mass: Mass,
+    ) : ExercisePerformanceTarget() {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is WeightTarget) return false
+
+            return mass == other.mass
+        }
+
+        override fun hashCode(): Int {
+            return mass.hashCode()
+        }
+
+        override fun toString(): String {
+            return "WeightTarget(mass=$mass)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires a target RPE (rate of perceived exertion) to be
+     * met during the associated {@link PlannedExerciseStep}.
+     *
+     * <p>Values correspond to the Borg CR10 RPE scale and must be in the range 0 to 10 inclusive.
+     * 0: No exertion (at rest) 1: Very light 2-3: Light 4-5: Moderate 6-7: Hard 8-9: Very hard 10:
+     * Maximum effort
+     */
+    class RateOfPerceivedExertionTarget(
+        val rpe: Int,
+    ) : ExercisePerformanceTarget() {
+        init {
+            require(rpe in 0..10) { "RPE value must be between 0 and 10, inclusive." }
+        }
+
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (other !is RateOfPerceivedExertionTarget) return false
+
+            return rpe == other.rpe
+        }
+
+        override fun hashCode(): Int {
+            return rpe.hashCode()
+        }
+
+        override fun toString(): String {
+            return "RateOfPerceivedExertionTarget(rpe=$rpe)"
+        }
+    }
+
+    /**
+     * An [ExercisePerformanceTarget] that requires completing as many repetitions as possible.
+     * AMRAP (as many reps as possible) sets are often used in conjunction with a duration based
+     * completion goal.
+     */
+    object AmrapTarget : ExercisePerformanceTarget() {
+        override fun toString(): String {
+            return "AmrapTarget()"
+        }
+    }
+
+    /** An [ExercisePerformanceTarget] that is unknown. */
+    object UnknownTarget : ExercisePerformanceTarget() {
+        override fun toString(): String {
+            return "UnknownTarget()"
+        }
+    }
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
index 8d7a3cf..9792a39 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
@@ -65,6 +65,7 @@
      * session.
      */
     val exerciseRouteResult: ExerciseRouteResult = ExerciseRouteResult.NoData(),
+    val plannedExerciseSessionId: String? = null
 ) : IntervalRecord {
 
     @JvmOverloads
@@ -83,6 +84,8 @@
         segments: List<ExerciseSegment> = emptyList(),
         laps: List<ExerciseLap> = emptyList(),
         exerciseRoute: ExerciseRoute? = null,
+        /** The planned exercise session this workout was based upon. Optional field. */
+        plannedExerciseSessionId: String? = null,
     ) : this(
         startTime,
         startZoneOffset,
@@ -94,7 +97,8 @@
         metadata,
         segments,
         laps,
-        exerciseRoute?.let { ExerciseRouteResult.Data(it) } ?: ExerciseRouteResult.NoData()
+        exerciseRoute?.let { ExerciseRouteResult.Data(it) } ?: ExerciseRouteResult.NoData(),
+        plannedExerciseSessionId,
     )
 
     init {
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseBlock.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseBlock.kt
new file mode 100644
index 0000000..555506c
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseBlock.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.health.connect.client.records
+/** Represents a series of [PlannedExerciseStep]s. Part of a [PlannedExerciseSessionRecord]. */
+class PlannedExerciseBlock(
+    val repetitions: Int,
+    val steps: List<PlannedExerciseStep>,
+    val description: String? = null,
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PlannedExerciseBlock) return false
+
+        if (repetitions != other.repetitions) return false
+        if (description != other.description) return false
+        if (steps != other.steps) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = repetitions
+        result = 31 * result + (description?.hashCode() ?: 0)
+        result = 31 * result + steps.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "PlannedExerciseBlock(repetitions=$repetitions, description=$description, steps=$steps)"
+    }
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseSessionRecord.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseSessionRecord.kt
new file mode 100644
index 0000000..b6a8a2f
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseSessionRecord.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.records.metadata.Metadata
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+
+/**
+ * Captures a planned exercise session, also commonly referred to as a training plan.
+ *
+ * <p>Each record contains a start time, end time, an exercise type and a list of
+ * [PlannedExerciseBlock] which describe the details of the planned session. The start and end times
+ * may be in the future.
+ */
+class PlannedExerciseSessionRecord
+internal constructor(
+    override val startTime: Instant,
+    override val startZoneOffset: ZoneOffset?,
+    override val endTime: Instant,
+    override val endZoneOffset: ZoneOffset?,
+    override val metadata: Metadata,
+    @get:JvmName("hasExplicitTime") val hasExplicitTime: Boolean,
+    /** Type of exercise (e.g. walking, swimming). Required field. */
+    @property:ExerciseSessionRecord.ExerciseTypes val exerciseType: Int,
+    /** The exercise session that completed this planned session. */
+    val completedExerciseSessionId: String?,
+    val blocks: List<PlannedExerciseBlock>,
+    /** Title of the session. Optional field. */
+    val title: String? = null,
+    /** Additional notes for the session. Optional field. */
+    val notes: String? = null,
+) : IntervalRecord {
+    /**
+     * Constructor that accepts a physical time and zone offset.
+     *
+     * @param startTime The time at which the session should start.
+     * @param startZoneOffset The zone offset at the start of this session. If null is provided,
+     *   this will default to the current system timezone.
+     * @param endTime The time at which the session should end.
+     * @param endZoneOffset The zone offset at the end of this session. If null is provided, this
+     *   will default to the current system timezone.
+     * @param blocks The [PlannedExerciseBlock]s that contain details of this session.
+     * @param title The title of this session.
+     * @param notes Notes for this session.
+     * @param exerciseType The exercise type of this session.
+     * @param metadata Metadata for this session.
+     */
+    @JvmOverloads
+    constructor(
+        startTime: Instant,
+        startZoneOffset: ZoneOffset?,
+        endTime: Instant,
+        endZoneOffset: ZoneOffset?,
+        blocks: List<PlannedExerciseBlock>,
+        /** Type of exercise (e.g. walking, swimming). Required field. */
+        exerciseType: Int,
+        /** Title of the session. Optional field. */
+        title: String? = null,
+        /** Additional notes for the session. Optional field. */
+        notes: String? = null,
+        metadata: Metadata = Metadata.EMPTY,
+    ) : this(
+        startTime,
+        startZoneOffset,
+        endTime,
+        endZoneOffset,
+        metadata,
+        true,
+        exerciseType,
+        null,
+        blocks,
+        title = title,
+        notes = notes,
+    )
+
+    /**
+     * Constructor that accepts a local date plus a duration. The start time will be implicitly
+     * generated from a local time at noon in the system default timezone on the day specified by
+     * [startDate]. The end time will be generated by adding [duration] to the start time.
+     *
+     * @param startDate The date on which the session should occur.
+     * @param duration The expected or estimated duration of the session.
+     * @param blocks The [PlannedExerciseBlock]s that contain details of this session.
+     * @param title The title of this session.
+     * @param notes Notes for this session.
+     * @param exerciseType The exercise type of this session.
+     * @param metadata Metadata for this session.
+     */
+    @JvmOverloads
+    constructor(
+        startDate: LocalDate,
+        duration: Duration,
+        blocks: List<PlannedExerciseBlock>,
+        /** Type of exercise (e.g. walking, swimming). Required field. */
+        exerciseType: Int,
+        /** Title of the session. Optional field. */
+        title: String? = null,
+        /** Additional notes for the session. Optional field. */
+        notes: String? = null,
+        metadata: Metadata = Metadata.EMPTY,
+    ) : this(
+        startDate.toPhysicalTimeAtNoon(),
+        startDate.toPhysicalTimeAtNoon().getOffset(),
+        startDate.toPhysicalTimeAtNoon().plus(duration),
+        startDate.toPhysicalTimeAtNoon().plus(duration).getOffset(),
+        metadata,
+        false,
+        exerciseType,
+        null,
+        blocks,
+        title = title,
+        notes = notes,
+    )
+
+    init {
+        require(startTime.isBefore(endTime))
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PlannedExerciseSessionRecord) return false
+
+        if (startTime != other.startTime) return false
+        if (startZoneOffset != other.startZoneOffset) return false
+        if (endTime != other.endTime) return false
+        if (endZoneOffset != other.endZoneOffset) return false
+        if (hasExplicitTime != other.hasExplicitTime) return false
+        if (blocks != other.blocks) return false
+        if (title != other.title) return false
+        if (notes != other.notes) return false
+        if (exerciseType != other.exerciseType) return false
+        if (metadata != other.metadata) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = startTime.hashCode()
+        result = 31 * result + (startZoneOffset?.hashCode() ?: 0)
+        result = 31 * result + endTime.hashCode()
+        result = 31 * result + (endZoneOffset?.hashCode() ?: 0)
+        result = 31 * result + hasExplicitTime.hashCode()
+        result = 31 * result + blocks.hashCode()
+        result = 31 * result + (title?.hashCode() ?: 0)
+        result = 31 * result + (notes?.hashCode() ?: 0)
+        result = 31 * result + exerciseType
+        result = 31 * result + (completedExerciseSessionId?.hashCode() ?: 0)
+        result = 31 * result + metadata.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "PlannedExerciseSessionRecord(startTime=$startTime, startZoneOffset=$startZoneOffset, endTime=$endTime, endZoneOffset=$endZoneOffset, hasExplicitTime=$hasExplicitTime, title=$title, notes=$notes, exerciseType=$exerciseType, completedExerciseSessionId=$completedExerciseSessionId, metadata=$metadata, blocks=$blocks)"
+    }
+
+    companion object {
+        /**
+         * Converts a local date to a physical timestamp by assuming a fixed time at noon and the
+         * current system time zone.
+         */
+        private fun LocalDate.toPhysicalTimeAtNoon(): Instant {
+            return this.atTime(LocalTime.NOON).atZone(ZoneId.systemDefault()).toInstant()
+        }
+
+        /** Gets the offset of the system default timezone at the given [Instant]. */
+        private fun Instant.getOffset(): ZoneOffset {
+            return ZoneOffset.systemDefault().rules.getOffset(this)
+        }
+    }
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseStep.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseStep.kt
new file mode 100644
index 0000000..a927b31
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/PlannedExerciseStep.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.health.connect.client.records
+
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+
+/**
+ * A single step within an [PlannedExerciseBlock] e.g. 8x 60kg barbell squats.
+ *
+ * @param exerciseType The type of exercise that this step involves.
+ * @param exercisePhase The phase e.g. 'warmup' that this step belongs to.
+ * @param description The description of this step.
+ * @param completionGoal The goal that must be completed to finish this step.
+ * @param performanceTargets Performance related targets that should be met during this step.
+ */
+class PlannedExerciseStep(
+    @property:ExerciseSegment.Companion.ExerciseSegmentTypes val exerciseType: Int,
+    @property:ExercisePhase val exercisePhase: Int,
+    val completionGoal: ExerciseCompletionGoal,
+    val performanceTargets: List<ExercisePerformanceTarget>,
+    val description: String? = null,
+) {
+    companion object {
+        /* Next Id: 6. */
+        /** An unknown phase of exercise. */
+        const val EXERCISE_PHASE_UNKNOWN = 0
+        /** A warmup. */
+        const val EXERCISE_PHASE_WARMUP = 1
+        /** A rest. */
+        const val EXERCISE_PHASE_REST = 2
+        /** Active exercise. */
+        const val EXERCISE_PHASE_ACTIVE = 3
+        /** Cooldown exercise, typically at the end of a workout. */
+        const val EXERCISE_PHASE_COOLDOWN = 4
+        /** Lower intensity, active exercise. */
+        const val EXERCISE_PHASE_RECOVERY = 5
+
+        /** List of supported exercise phase types. */
+        @Retention(AnnotationRetention.SOURCE)
+        @RestrictTo(RestrictTo.Scope.LIBRARY)
+        @IntDef(
+            value =
+                [
+                    EXERCISE_PHASE_UNKNOWN,
+                    EXERCISE_PHASE_WARMUP,
+                    EXERCISE_PHASE_REST,
+                    EXERCISE_PHASE_ACTIVE,
+                    EXERCISE_PHASE_COOLDOWN,
+                    EXERCISE_PHASE_RECOVERY,
+                ]
+        )
+        annotation class ExercisePhase
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PlannedExerciseStep) return false
+
+        if (exerciseType != other.exerciseType) return false
+        if (exercisePhase != other.exercisePhase) return false
+        if (description != other.description) return false
+        if (completionGoal != other.completionGoal) return false
+        if (performanceTargets != other.performanceTargets) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = exerciseType
+        result = 31 * result + exercisePhase
+        result = 31 * result + (description?.hashCode() ?: 0)
+        result = 31 * result + completionGoal.hashCode()
+        result = 31 * result + performanceTargets.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "PlannedExerciseStep(exerciseType=$exerciseType, exerciseCategory=$exercisePhase, description=$description, completionGoal=$completionGoal, performanceTargets=$performanceTargets)"
+    }
+}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseCompletionGoalTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseCompletionGoalTest.kt
new file mode 100644
index 0000000..ab01101
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseCompletionGoalTest.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.records.ExerciseCompletionGoal.ActiveCaloriesBurnedGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.DistanceAndDurationGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.DistanceGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.DurationGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.ManualCompletion
+import androidx.health.connect.client.records.ExerciseCompletionGoal.RepetitionsGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.StepsGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.TotalCaloriesBurnedGoal
+import androidx.health.connect.client.records.ExerciseCompletionGoal.UnknownGoal
+import androidx.health.connect.client.units.calories
+import androidx.health.connect.client.units.meters
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ExerciseCompletionGoalTest {
+    @Test
+    fun distanceGoal_hashCodeAndEquals() {
+        val distanceGoal1 = DistanceGoal(20.0.meters)
+        val distanceGoal1Duplicate = DistanceGoal(20.0.meters)
+        val distanceGoal2 = DistanceGoal(30.0.meters)
+
+        assertThat(distanceGoal1.hashCode()).isNotEqualTo(distanceGoal2.hashCode())
+        assertThat(distanceGoal1).isNotEqualTo(distanceGoal2)
+        assertThat(distanceGoal1.hashCode()).isEqualTo(distanceGoal1Duplicate.hashCode())
+        assertThat(distanceGoal1).isEqualTo(distanceGoal1Duplicate)
+    }
+
+    @Test
+    fun distanceWithVariableRestGoal_hashCodeAndEquals() {
+        val distanceAndDurationGoal1 = DistanceAndDurationGoal(20.0.meters, Duration.ofMinutes(2))
+        val distanceAndDurationGoal1Duplicate =
+            DistanceAndDurationGoal(20.0.meters, Duration.ofMinutes(2))
+        val distanceAndDurationGoal2 = DistanceAndDurationGoal(20.0.meters, Duration.ofMinutes(4))
+
+        assertThat(distanceAndDurationGoal1.hashCode())
+            .isNotEqualTo(distanceAndDurationGoal2.hashCode())
+        assertThat(distanceAndDurationGoal1).isNotEqualTo(distanceAndDurationGoal2)
+        assertThat(distanceAndDurationGoal1.hashCode())
+            .isEqualTo(distanceAndDurationGoal1Duplicate.hashCode())
+        assertThat(distanceAndDurationGoal1).isEqualTo(distanceAndDurationGoal1Duplicate)
+    }
+
+    @Test
+    fun stepsGoal_hashCodeAndEquals() {
+        val stepsGoal1 = StepsGoal(1000)
+        val stepsGoal1Duplicate = StepsGoal(1000)
+        val stepsGoal2 = StepsGoal(2000)
+
+        assertThat(stepsGoal1.hashCode()).isNotEqualTo(stepsGoal2.hashCode())
+        assertThat(stepsGoal1).isNotEqualTo(stepsGoal2)
+        assertThat(stepsGoal1.hashCode()).isEqualTo(stepsGoal1Duplicate.hashCode())
+        assertThat(stepsGoal1).isEqualTo(stepsGoal1Duplicate)
+    }
+
+    @Test
+    fun durationGoal_hashCodeAndEquals() {
+        val durationGoal1 = DurationGoal(Duration.ofMinutes(30))
+        val durationGoal1Duplicate = DurationGoal(Duration.ofMinutes(30))
+        val durationGoal2 = DurationGoal(Duration.ofMinutes(60))
+
+        assertThat(durationGoal1.hashCode()).isNotEqualTo(durationGoal2.hashCode())
+        assertThat(durationGoal1).isNotEqualTo(durationGoal2)
+        assertThat(durationGoal1.hashCode()).isEqualTo(durationGoal1Duplicate.hashCode())
+        assertThat(durationGoal1).isEqualTo(durationGoal1Duplicate)
+    }
+
+    @Test
+    fun repetitionsGoal_hashCodeAndEquals() {
+        val repetitionsGoal1 = RepetitionsGoal(Duration.ofSeconds(10))
+        val repetitionsGoal1Duplicate = RepetitionsGoal(Duration.ofSeconds(10))
+        val repetitionsGoal2 = RepetitionsGoal(Duration.ofSeconds(20))
+
+        assertThat(repetitionsGoal1.hashCode()).isNotEqualTo(repetitionsGoal2.hashCode())
+        assertThat(repetitionsGoal1).isNotEqualTo(repetitionsGoal2)
+        assertThat(repetitionsGoal1.hashCode()).isEqualTo(repetitionsGoal1Duplicate.hashCode())
+        assertThat(repetitionsGoal1).isEqualTo(repetitionsGoal1Duplicate)
+    }
+
+    @Test
+    fun totalCaloriesBurnedGoal_hashCodeAndEquals() {
+        val totalCaloriesBurnedGoal1 = TotalCaloriesBurnedGoal(100.calories)
+        val totalCaloriesBurnedGoal1Duplicate = TotalCaloriesBurnedGoal(100.calories)
+        val totalCaloriesBurnedGoal2 = TotalCaloriesBurnedGoal(200.calories)
+
+        assertThat(totalCaloriesBurnedGoal1.hashCode())
+            .isNotEqualTo(totalCaloriesBurnedGoal2.hashCode())
+        assertThat(totalCaloriesBurnedGoal1).isNotEqualTo(totalCaloriesBurnedGoal2)
+        assertThat(totalCaloriesBurnedGoal1.hashCode())
+            .isEqualTo(totalCaloriesBurnedGoal1Duplicate.hashCode())
+        assertThat(totalCaloriesBurnedGoal1).isEqualTo(totalCaloriesBurnedGoal1Duplicate)
+    }
+
+    @Test
+    fun activeCaloriesBurnedGoal_hashCodeAndEquals() {
+        val activeCaloriesBurnedGoal1 = ActiveCaloriesBurnedGoal(100.calories)
+        val activeCaloriesBurnedGoal1Duplicate = ActiveCaloriesBurnedGoal(100.calories)
+        val activeCaloriesBurnedGoal2 = ActiveCaloriesBurnedGoal(200.calories)
+
+        assertThat(activeCaloriesBurnedGoal1.hashCode())
+            .isNotEqualTo(activeCaloriesBurnedGoal2.hashCode())
+        assertThat(activeCaloriesBurnedGoal1).isNotEqualTo(activeCaloriesBurnedGoal2)
+        assertThat(activeCaloriesBurnedGoal1.hashCode())
+            .isEqualTo(activeCaloriesBurnedGoal1Duplicate.hashCode())
+        assertThat(activeCaloriesBurnedGoal1).isEqualTo(activeCaloriesBurnedGoal1Duplicate)
+    }
+
+    @Test
+    fun unknownGoal_hashCodeAndEquals() {
+        assertThat(UnknownGoal.hashCode()).isEqualTo(UnknownGoal.hashCode())
+        assertThat(UnknownGoal).isEqualTo(UnknownGoal)
+    }
+
+    @Test
+    fun unspecifiedGoal_hashCodeAndEquals() {
+        assertThat(ManualCompletion).isEqualTo(ManualCompletion)
+        assertThat(ManualCompletion).isEqualTo(ManualCompletion)
+    }
+}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExercisePerformanceTargetTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExercisePerformanceTargetTest.kt
new file mode 100644
index 0000000..5ac3c62
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExercisePerformanceTargetTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.records.ExercisePerformanceTarget.AmrapTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.CadenceTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.HeartRateTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.PowerTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.RateOfPerceivedExertionTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.SpeedTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.UnknownTarget
+import androidx.health.connect.client.records.ExercisePerformanceTarget.WeightTarget
+import androidx.health.connect.client.units.kilograms
+import androidx.health.connect.client.units.metersPerSecond
+import androidx.health.connect.client.units.watts
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ExercisePerformanceTargetTest {
+    @Test
+    fun powerTarget_hashCodeAndEquals() {
+        val target1 = PowerTarget(minPower = 10.watts, maxPower = 20.watts)
+        val target1Copy = PowerTarget(minPower = 10.watts, maxPower = 20.watts)
+        val target2 = PowerTarget(minPower = 15.watts, maxPower = 25.watts)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun speedTarget_hashCodeAndEquals() {
+        val target1 = SpeedTarget(minSpeed = 10.metersPerSecond, maxSpeed = 20.metersPerSecond)
+        val target1Copy = SpeedTarget(minSpeed = 10.metersPerSecond, maxSpeed = 20.metersPerSecond)
+        val target2 = SpeedTarget(minSpeed = 15.metersPerSecond, maxSpeed = 25.metersPerSecond)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun cadenceTarget_hashCodeAndEquals() {
+        val target1 = CadenceTarget(minCadence = 10.0, maxCadence = 20.0)
+        val target1Copy = CadenceTarget(minCadence = 10.0, maxCadence = 20.0)
+        val target2 = CadenceTarget(minCadence = 15.0, maxCadence = 25.0)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun heartRateTarget_hashCodeAndEquals() {
+        val target1 = HeartRateTarget(minHeartRate = 100.0, maxHeartRate = 120.0)
+        val target1Copy = HeartRateTarget(minHeartRate = 100.0, maxHeartRate = 120.0)
+        val target2 = HeartRateTarget(minHeartRate = 110.0, maxHeartRate = 130.0)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun weightTarget_hashCodeAndEquals() {
+        val target1 = WeightTarget(mass = 100.kilograms)
+        val target1Copy = WeightTarget(mass = 100.kilograms)
+        val target2 = WeightTarget(mass = 120.kilograms)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun rateOfPerceivedExertionTarget_hashCodeAndEquals() {
+        val target1 = RateOfPerceivedExertionTarget(rpe = 5)
+        val target1Copy = RateOfPerceivedExertionTarget(rpe = 5)
+        val target2 = RateOfPerceivedExertionTarget(rpe = 7)
+
+        assertThat(target1.hashCode()).isNotEqualTo(target2.hashCode())
+        assertThat(target1).isNotEqualTo(target2)
+        assertThat(target1.hashCode()).isEqualTo(target1Copy.hashCode())
+        assertThat(target1).isEqualTo(target1Copy)
+    }
+
+    @Test
+    fun amrapTarget_hashCodeAndEquals() {
+        assertThat(AmrapTarget.hashCode()).isEqualTo(AmrapTarget.hashCode())
+        assertThat(AmrapTarget).isEqualTo(AmrapTarget)
+    }
+
+    @Test
+    fun unknownTarget_hashCodeAndEquals() {
+        assertThat(UnknownTarget.hashCode()).isEqualTo(UnknownTarget.hashCode())
+        assertThat(UnknownTarget).isEqualTo(UnknownTarget)
+    }
+}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
index 49709f7..bf8240c 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
@@ -555,4 +555,21 @@
                 "ExerciseSessionRecord(startTime=1970-01-01T00:00:01.234Z, startZoneOffset=null, endTime=1970-01-01T00:00:01.236Z, endZoneOffset=null, exerciseType=8, title=title, notes=notes, metadata=Metadata(id='', dataOrigin=DataOrigin(packageName=''), lastModifiedTime=1970-01-01T00:00:00Z, clientRecordId=null, clientRecordVersion=0, device=null, recordingMethod=0), segments=[ExerciseSegment(startTime=1970-01-01T00:00:01.234Z, endTime=1970-01-01T00:00:01.235Z, segmentType=7, repetitions=0)], laps=[ExerciseLap(startTime=1970-01-01T00:00:01.235Z, endTime=1970-01-01T00:00:01.236Z, length=10.0 meters)], exerciseRouteResult=Data(exerciseRoute=ExerciseRoute(route=[Location(time=1970-01-01T00:00:01.234Z, latitude=34.5, longitude=-34.5, horizontalAccuracy=0.4 meters, verticalAccuracy=1.3 meters, altitude=23.4 meters)])))"
             )
     }
+
+    @Test
+    fun plannedExercise_fieldCanBeOptionallySet() {
+        assertThat(
+                ExerciseSessionRecord(
+                        startTime = Instant.ofEpochMilli(1234L),
+                        startZoneOffset = null,
+                        endTime = Instant.ofEpochMilli(1236L),
+                        endZoneOffset = null,
+                        exerciseType = EXERCISE_TYPE_BIKING,
+                        exerciseRoute = null,
+                        plannedExerciseSessionId = "some_id"
+                    )
+                    .plannedExerciseSessionId
+            )
+            .isEqualTo("some_id")
+    }
 }
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseBlockTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseBlockTest.kt
new file mode 100644
index 0000000..e2e5dac
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseBlockTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.units.kilometers
+import androidx.health.connect.client.units.meters
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlannedExerciseBlockTest {
+
+    @Test
+    fun identicalBlocks_bothAreEqual() {
+        assertThat(
+                PlannedExerciseBlock(
+                    repetitions = 2,
+                    description = "Main set",
+                    steps =
+                        listOf(
+                            PlannedExerciseStep(
+                                ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                                PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                                completionGoal = ExerciseCompletionGoal.DistanceGoal(3.kilometers),
+                                performanceTargets = listOf()
+                            )
+                        )
+                )
+            )
+            .isEqualTo(
+                PlannedExerciseBlock(
+                    repetitions = 2,
+                    description = "Main set",
+                    steps =
+                        listOf(
+                            PlannedExerciseStep(
+                                ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                                PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                                completionGoal = ExerciseCompletionGoal.DistanceGoal(3.kilometers),
+                                performanceTargets = listOf()
+                            )
+                        )
+                )
+            )
+    }
+
+    @Test
+    fun differentBlocks_notEqual() {
+        assertThat(
+                PlannedExerciseBlock(
+                    repetitions = 2,
+                    description = "Main set",
+                    steps =
+                        listOf(
+                            PlannedExerciseStep(
+                                ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                                PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                                completionGoal = ExerciseCompletionGoal.DistanceGoal(1.kilometers),
+                                performanceTargets = listOf()
+                            )
+                        )
+                )
+            )
+            .isNotEqualTo(
+                PlannedExerciseBlock(
+                    repetitions = 3,
+                    description = "Warmup",
+                    steps =
+                        listOf(
+                            PlannedExerciseStep(
+                                ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                completionGoal = ExerciseCompletionGoal.DistanceGoal(200.meters),
+                                performanceTargets = listOf()
+                            )
+                        )
+                )
+            )
+    }
+}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseSessionRecordTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseSessionRecordTest.kt
new file mode 100644
index 0000000..04236b2
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseSessionRecordTest.kt
@@ -0,0 +1,457 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.Power
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlannedExerciseSessionRecordTest {
+
+    @Test
+    fun identicalRecords_bothAreEqual() {
+        assertThat(
+                PlannedExerciseSessionRecord(
+                    startTime = Instant.ofEpochMilli(1234L),
+                    startZoneOffset = null,
+                    endTime = Instant.ofEpochMilli(1236L),
+                    endZoneOffset = null,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(180.0),
+                                                        maxPower = Power.watts(220.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+            .isEqualTo(
+                PlannedExerciseSessionRecord(
+                    startTime = Instant.ofEpochMilli(1234L),
+                    startZoneOffset = null,
+                    endTime = Instant.ofEpochMilli(1236L),
+                    endZoneOffset = null,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(180.0),
+                                                        maxPower = Power.watts(220.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+    }
+
+    @Test
+    fun differentRecords_notEqual() {
+        assertThat(
+                PlannedExerciseSessionRecord(
+                    startTime = Instant.ofEpochMilli(1234L),
+                    startZoneOffset = null,
+                    endTime = Instant.ofEpochMilli(1236L),
+                    endZoneOffset = null,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                2,
+                                description = "Main set",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                                            PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(3000.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(200.0),
+                                                        maxPower = Power.watts(240.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+            .isNotEqualTo(
+                PlannedExerciseSessionRecord(
+                    startTime = Instant.ofEpochMilli(1234L),
+                    startZoneOffset = null,
+                    endTime = Instant.ofEpochMilli(1236L),
+                    endZoneOffset = null,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets = listOf()
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+    }
+
+    @Test
+    fun invalidTimes_startAfterEnd_throws() {
+        assertFailsWith<IllegalArgumentException> {
+            PlannedExerciseSessionRecord(
+                startTime = Instant.ofEpochMilli(100L),
+                startZoneOffset = null,
+                endTime = Instant.ofEpochMilli(50L),
+                endZoneOffset = null,
+                blocks =
+                    listOf(
+                        PlannedExerciseBlock(
+                            3,
+                            description = "Warmup",
+                            steps =
+                                listOf(
+                                    PlannedExerciseStep(
+                                        ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                        PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                        completionGoal =
+                                            ExerciseCompletionGoal.DistanceGoal(
+                                                Length.meters(200.0)
+                                            ),
+                                        performanceTargets =
+                                            listOf(
+                                                ExercisePerformanceTarget.PowerTarget(
+                                                    minPower = Power.watts(180.0),
+                                                    maxPower = Power.watts(220.0)
+                                                )
+                                            )
+                                    )
+                                )
+                        )
+                    ),
+                title = "Total Body Conditioning Workout",
+                notes = "A tough workout that mixes both cardio and strength!",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+            )
+        }
+    }
+
+    @Test
+    fun identicalRecords_localDateConstructor_bothAreEqual() {
+        val startDate = LocalDate.of(2022, 12, 31)
+        val duration = Duration.ofHours(1)
+
+        assertThat(
+                PlannedExerciseSessionRecord(
+                    startDate = startDate,
+                    duration = duration,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(180.0),
+                                                        maxPower = Power.watts(220.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+            .isEqualTo(
+                PlannedExerciseSessionRecord(
+                    startDate = startDate,
+                    duration = duration,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(180.0),
+                                                        maxPower = Power.watts(220.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+    }
+
+    @Test
+    fun differentRecords_localDateConstructor_notEqual() {
+        val startDate = LocalDate.of(2022, 12, 31)
+        val duration = Duration.ofHours(1)
+
+        assertThat(
+                PlannedExerciseSessionRecord(
+                    startDate = startDate,
+                    duration = duration,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                2,
+                                description = "Main set",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                                            PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(3000.0)
+                                                ),
+                                            performanceTargets =
+                                                listOf(
+                                                    ExercisePerformanceTarget.PowerTarget(
+                                                        minPower = Power.watts(200.0),
+                                                        maxPower = Power.watts(240.0)
+                                                    )
+                                                )
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+            .isNotEqualTo(
+                PlannedExerciseSessionRecord(
+                    startDate = startDate,
+                    duration = duration,
+                    blocks =
+                        listOf(
+                            PlannedExerciseBlock(
+                                3,
+                                description = "Warmup",
+                                steps =
+                                    listOf(
+                                        PlannedExerciseStep(
+                                            ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                                            PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                                            completionGoal =
+                                                ExerciseCompletionGoal.DistanceGoal(
+                                                    Length.meters(200.0)
+                                                ),
+                                            performanceTargets = listOf()
+                                        )
+                                    )
+                            )
+                        ),
+                    title = "Total Body Conditioning Workout",
+                    notes = "A tough workout that mixes both cardio and strength!",
+                    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                )
+            )
+    }
+
+    @Test
+    fun completedExerciseSessionId_setsCorrectly() {
+        // Note: this can only be set via the internal constructor.
+        val record =
+            PlannedExerciseSessionRecord(
+                startTime = Instant.ofEpochMilli(1234L),
+                startZoneOffset = null,
+                endTime = Instant.ofEpochMilli(1236L),
+                endZoneOffset = null,
+                hasExplicitTime = true,
+                blocks = listOf(),
+                title = "My Planned Session",
+                notes = "Notes",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+                completedExerciseSessionId = "some-uuid",
+                metadata = Metadata("record_id", DataOrigin("com.some.app"))
+            )
+        assertThat(record.completedExerciseSessionId).isEqualTo("some-uuid")
+    }
+
+    @Test
+    fun completedExerciseSessionId_defaultsToNull() {
+        val record =
+            PlannedExerciseSessionRecord(
+                startTime = Instant.ofEpochMilli(1234L),
+                startZoneOffset = null,
+                endTime = Instant.ofEpochMilli(1236L),
+                endZoneOffset = null,
+                blocks = listOf(),
+                title = "My Planned Session",
+                notes = "Notes",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS
+            )
+        assertThat(record.completedExerciseSessionId).isNull()
+    }
+
+    @Test
+    fun localDateConstructor_implicitlySetsStartAndEndTime() {
+        val startDate = LocalDate.of(2023, 10, 26)
+        val record =
+            PlannedExerciseSessionRecord(
+                startDate = startDate,
+                duration = Duration.ofHours(1),
+                blocks = listOf(),
+                title = "My Planned Session",
+                notes = "Notes",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+            )
+
+        assertThat(record.startTime)
+            .isEqualTo(startDate.atTime(LocalTime.NOON).atZone(ZoneId.systemDefault()).toInstant())
+        assertThat(record.endTime)
+            .isEqualTo(
+                startDate
+                    .atTime(LocalTime.NOON)
+                    .atZone(ZoneId.systemDefault())
+                    .toInstant()
+                    .plus(Duration.ofHours(1))
+            )
+    }
+
+    @Test
+    fun localDateConstructor_hasExplicitTimeIsFalse() {
+        val record =
+            PlannedExerciseSessionRecord(
+                startDate = LocalDate.now(),
+                duration = Duration.ofMinutes(30),
+                blocks = listOf(),
+                title = "My Planned Session",
+                notes = "Notes",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+            )
+        assertThat(record.hasExplicitTime).isFalse()
+    }
+
+    @Test
+    fun instantConstructor_hasExplicitTimeIsTrue() {
+        val record =
+            PlannedExerciseSessionRecord(
+                startTime = Instant.now(),
+                startZoneOffset = null,
+                endTime = Instant.now().plusSeconds(1800),
+                endZoneOffset = null,
+                blocks = listOf(),
+                title = "My Planned Session",
+                notes = "Notes",
+                exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+            )
+        assertThat(record.hasExplicitTime).isTrue()
+    }
+}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseStepTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseStepTest.kt
new file mode 100644
index 0000000..e24aa3d
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/PlannedExerciseStepTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.records
+
+import androidx.health.connect.client.units.kilometers
+import androidx.health.connect.client.units.meters
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PlannedExerciseStepTest {
+
+    @Test
+    fun identicalSteps_bothAreEqual() {
+        assertThat(
+                PlannedExerciseStep(
+                    ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                    PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                    ExerciseCompletionGoal.DistanceGoal(1.kilometers),
+                    listOf(),
+                    "Run fast for 1km",
+                )
+            )
+            .isEqualTo(
+                PlannedExerciseStep(
+                    ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                    PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                    ExerciseCompletionGoal.DistanceGoal(1.kilometers),
+                    listOf(),
+                    "Run fast for 1km",
+                )
+            )
+    }
+
+    @Test
+    fun differentSteps_notEqual() {
+        assertThat(
+                PlannedExerciseStep(
+                    ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
+                    PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
+                    ExerciseCompletionGoal.DistanceGoal(1.kilometers),
+                    listOf(),
+                    "Run fast for 1km",
+                )
+            )
+            .isNotEqualTo(
+                PlannedExerciseStep(
+                    ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY,
+                    PlannedExerciseStep.EXERCISE_PHASE_WARMUP,
+                    ExerciseCompletionGoal.DistanceGoal(200.meters),
+                    listOf(),
+                    "Warmup",
+                )
+            )
+    }
+}
diff --git a/libraryversions.toml b/libraryversions.toml
index 7125ef8..77580b6 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -107,7 +107,7 @@
 PRIVACYSANDBOX_ADS = "1.1.0-beta10"
 PRIVACYSANDBOX_PLUGINS = "1.0.0-alpha03"
 PRIVACYSANDBOX_SDKRUNTIME = "1.0.0-alpha14"
-PRIVACYSANDBOX_TOOLS = "1.0.0-alpha09"
+PRIVACYSANDBOX_TOOLS = "1.0.0-alpha10"
 PRIVACYSANDBOX_UI = "1.0.0-alpha10"
 PROFILEINSTALLER = "1.4.0-rc01"
 RECOMMENDATION = "1.1.0-alpha01"
diff --git a/lifecycle/lifecycle-runtime/api/current.txt b/lifecycle/lifecycle-runtime/api/current.txt
index 4ba40ea..d0ae223 100644
--- a/lifecycle/lifecycle-runtime/api/current.txt
+++ b/lifecycle/lifecycle-runtime/api/current.txt
@@ -11,13 +11,13 @@
 
   public class LifecycleRegistry extends androidx.lifecycle.Lifecycle {
     ctor public LifecycleRegistry(androidx.lifecycle.LifecycleOwner provider);
-    method public void addObserver(androidx.lifecycle.LifecycleObserver observer);
+    method @MainThread public void addObserver(androidx.lifecycle.LifecycleObserver observer);
     method @VisibleForTesting public static final androidx.lifecycle.LifecycleRegistry createUnsafe(androidx.lifecycle.LifecycleOwner owner);
     method public androidx.lifecycle.Lifecycle.State getCurrentState();
     method public int getObserverCount();
     method public void handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event event);
     method @Deprecated @MainThread public void markState(androidx.lifecycle.Lifecycle.State state);
-    method public void removeObserver(androidx.lifecycle.LifecycleObserver observer);
+    method @MainThread public void removeObserver(androidx.lifecycle.LifecycleObserver observer);
     method public void setCurrentState(androidx.lifecycle.Lifecycle.State);
     property public androidx.lifecycle.Lifecycle.State currentState;
     property public kotlinx.coroutines.flow.StateFlow<androidx.lifecycle.Lifecycle.State> currentStateFlow;
diff --git a/lifecycle/lifecycle-runtime/api/restricted_current.txt b/lifecycle/lifecycle-runtime/api/restricted_current.txt
index 1f60a847..f925edd 100644
--- a/lifecycle/lifecycle-runtime/api/restricted_current.txt
+++ b/lifecycle/lifecycle-runtime/api/restricted_current.txt
@@ -11,13 +11,13 @@
 
   public class LifecycleRegistry extends androidx.lifecycle.Lifecycle {
     ctor public LifecycleRegistry(androidx.lifecycle.LifecycleOwner provider);
-    method public void addObserver(androidx.lifecycle.LifecycleObserver observer);
+    method @MainThread public void addObserver(androidx.lifecycle.LifecycleObserver observer);
     method @VisibleForTesting public static final androidx.lifecycle.LifecycleRegistry createUnsafe(androidx.lifecycle.LifecycleOwner owner);
     method public androidx.lifecycle.Lifecycle.State getCurrentState();
     method public int getObserverCount();
     method public void handleLifecycleEvent(androidx.lifecycle.Lifecycle.Event event);
     method @Deprecated @MainThread public void markState(androidx.lifecycle.Lifecycle.State state);
-    method public void removeObserver(androidx.lifecycle.LifecycleObserver observer);
+    method @MainThread public void removeObserver(androidx.lifecycle.LifecycleObserver observer);
     method public void setCurrentState(androidx.lifecycle.Lifecycle.State);
     property public androidx.lifecycle.Lifecycle.State currentState;
     property public kotlinx.coroutines.flow.StateFlow<androidx.lifecycle.Lifecycle.State> currentStateFlow;
diff --git a/lifecycle/lifecycle-runtime/src/commonMain/kotlin/androidx/lifecycle/LifecycleRegistry.kt b/lifecycle/lifecycle-runtime/src/commonMain/kotlin/androidx/lifecycle/LifecycleRegistry.kt
index 0971d24..6b4de36 100644
--- a/lifecycle/lifecycle-runtime/src/commonMain/kotlin/androidx/lifecycle/LifecycleRegistry.kt
+++ b/lifecycle/lifecycle-runtime/src/commonMain/kotlin/androidx/lifecycle/LifecycleRegistry.kt
@@ -15,6 +15,7 @@
  */
 package androidx.lifecycle
 
+import androidx.annotation.MainThread
 import androidx.annotation.VisibleForTesting
 import kotlin.jvm.JvmStatic
 
@@ -37,6 +38,10 @@
 constructor(provider: LifecycleOwner) : Lifecycle {
     override var currentState: State
 
+    @MainThread() override fun addObserver(observer: LifecycleObserver)
+
+    @MainThread() override fun removeObserver(observer: LifecycleObserver)
+
     /**
      * Sets the current state and notifies the observers.
      *
diff --git a/lifecycle/lifecycle-runtime/src/jvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.jvm.kt b/lifecycle/lifecycle-runtime/src/jvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.jvm.kt
index 5342565..38f21ec 100644
--- a/lifecycle/lifecycle-runtime/src/jvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.jvm.kt
+++ b/lifecycle/lifecycle-runtime/src/jvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.jvm.kt
@@ -169,7 +169,8 @@
      * @param observer The observer to notify.
      * @throws IllegalStateException if no event up from observer's initial state
      */
-    override fun addObserver(observer: LifecycleObserver) {
+    @MainThread
+    actual override fun addObserver(observer: LifecycleObserver) {
         enforceMainThreadIfNeeded("addObserver")
         val initialState = if (state == State.DESTROYED) State.DESTROYED else State.INITIALIZED
         val statefulObserver = ObserverWithState(observer, initialState)
@@ -209,7 +210,8 @@
         parentStates.add(state)
     }
 
-    override fun removeObserver(observer: LifecycleObserver) {
+    @MainThread
+    actual override fun removeObserver(observer: LifecycleObserver) {
         enforceMainThreadIfNeeded("removeObserver")
         // we consciously decided not to send destruction events here in opposition to addObserver.
         // Our reasons for that:
diff --git a/lifecycle/lifecycle-runtime/src/nonJvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.nonJvm.kt b/lifecycle/lifecycle-runtime/src/nonJvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.nonJvm.kt
index e509628..bb30846 100644
--- a/lifecycle/lifecycle-runtime/src/nonJvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.nonJvm.kt
+++ b/lifecycle/lifecycle-runtime/src/nonJvmCommonMain/kotlin/androidx/lifecycle/LifecycleRegistry.nonJvm.kt
@@ -15,6 +15,7 @@
  */
 package androidx.lifecycle
 
+import androidx.annotation.MainThread
 import androidx.annotation.VisibleForTesting
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -155,7 +156,8 @@
      * @param observer The observer to notify.
      * @throws IllegalStateException if no event up from observer's initial state
      */
-    override fun addObserver(observer: LifecycleObserver) {
+    @MainThread
+    actual override fun addObserver(observer: LifecycleObserver) {
         enforceMainThreadIfNeeded("addObserver")
         val initialState = if (state == State.DESTROYED) State.DESTROYED else State.INITIALIZED
         val statefulObserver = ObserverWithState(observer, initialState)
@@ -195,7 +197,8 @@
         parentStates.add(state)
     }
 
-    override fun removeObserver(observer: LifecycleObserver) {
+    @MainThread
+    actual override fun removeObserver(observer: LifecycleObserver) {
         enforceMainThreadIfNeeded("removeObserver")
         // we consciously decided not to send destruction events here in opposition to addObserver.
         // Our reasons for that:
diff --git a/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt b/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
index 4799f00..b015d75 100644
--- a/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
@@ -87,6 +87,7 @@
                     JSpecifyNullnessMigration.ISSUE,
                     TypeMirrorToString.ISSUE,
                     BanNullMarked.ISSUE,
+                    AutoValueNullnessOverride.ISSUE,
                 )
             }
     }
diff --git a/lint-checks/src/main/java/androidx/build/lint/AutoValueNullnessOverride.kt b/lint-checks/src/main/java/androidx/build/lint/AutoValueNullnessOverride.kt
new file mode 100644
index 0000000..4fca66f
--- /dev/null
+++ b/lint-checks/src/main/java/androidx/build/lint/AutoValueNullnessOverride.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.build.lint
+
+import com.android.tools.lint.client.api.UElementHandler
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Incident
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.LocationType
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.getUMethod
+import com.android.tools.lint.detector.api.isJava
+import com.android.tools.lint.model.LintModelMavenName
+import com.intellij.psi.PsiMember
+import com.intellij.psi.PsiMethod
+import java.util.EnumSet
+import org.jetbrains.uast.UClass
+
+/**
+ * Enforces that the workaround for b/237064488 is applied, see issue definition for more detail.
+ */
+class AutoValueNullnessOverride : Detector(), Detector.UastScanner {
+    override fun getApplicableUastTypes() = listOf(UClass::class.java)
+
+    override fun createUastHandler(context: JavaContext): UElementHandler {
+        return ClassChecker(context)
+    }
+
+    private inner class ClassChecker(val context: JavaContext) : UElementHandler() {
+        override fun visitClass(node: UClass) {
+            if (!node.hasAnnotation("com.google.auto.value.AutoValue")) return
+
+            val classCoordinates = context.findMavenCoordinate(node.javaPsi)
+
+            // Narrow to the relevant methods
+            val missingOverrides =
+                node.allMethods.filter {
+                    // Abstract getters are the ones used by the autovalue for builder generation
+                    it.isAbstractGetter() &&
+                        it.isNullable() &&
+                        node.isSuperMethodWithoutOverride(it) &&
+                        isFromDifferentCompilation(it, classCoordinates)
+                }
+
+            if (missingOverrides.isEmpty()) return
+
+            // Add overrides that are just copies of the parent source code
+            val insertionText =
+                missingOverrides
+                    .mapNotNull {
+                        it.getUMethod()?.asSourceString()?.let { parentMethod ->
+                            "\n@Override\n$parentMethod"
+                        }
+                    }
+                    .joinToString("\n")
+            val fix =
+                if (isJava(node.language) && insertionText.isNotBlank()) {
+                    fix()
+                        .replace()
+                        // Find the opening of the class body and insert after that
+                        .pattern("\\{()")
+                        .with(insertionText)
+                        .reformat(true)
+                        .shortenNames()
+                        .range(context.getLocation(node, LocationType.ALL))
+                        .build()
+                } else {
+                    null
+                }
+
+            val methodNames = missingOverrides.joinToString(", ") { "${it.name}()" }
+            val incident =
+                Incident(context)
+                    .issue(ISSUE)
+                    .message("Methods need @Nullable overrides for AutoValue: $methodNames")
+                    .location(context.getNameLocation(node))
+                    .fix(fix)
+
+            context.report(incident)
+        }
+
+        private fun PsiMethod.isAbstractGetter() =
+            parameterList.isEmpty && modifierList.hasModifierProperty("abstract")
+
+        /** Checks if the method return type uses the JSpecify @Nullable. */
+        private fun PsiMethod.isNullable() =
+            returnType?.hasAnnotation("org.jspecify.annotations.Nullable") == true
+
+        /**
+         * Checks that the method is defined in a different class and that the method is not also
+         * defined by a lower class in the hierarchy.
+         */
+        private fun UClass.isSuperMethodWithoutOverride(method: PsiMethod) =
+            method.containingClass?.qualifiedName != qualifiedName &&
+                // This searches starting with the class and then goes to the parent. So if it finds
+                // a matching method that isn't this method, there's an override lower down.
+                findMethodBySignature(method, true) == method
+
+        /**
+         * Checks if [member] has different maven coordinates than the [reference] coordinates, or
+         * if this is in a test context. Tests are in a different compilation from the main source
+         * but have the same maven coordinates, so to be safe always flag them.
+         */
+        private fun isFromDifferentCompilation(member: PsiMember, reference: LintModelMavenName?) =
+            context.findMavenCoordinate(member.containingClass!!) != reference ||
+                context.isTestSource
+    }
+
+    companion object {
+        val ISSUE =
+            Issue.create(
+                "AutoValueNullnessOverride",
+                "AutoValue classes must override @Nullable methods inherited from other projects",
+                """
+                    Due to a javac bug in JDK 21 and lower, AutoValue cannot see type-use nullness
+                    annotations from other compilations. @AutoValue classes that inherit @Nullable
+                    methods must provide an override so the AutoValue compiler doesn't make the
+                    value non-null. See b/237064488 for more information.
+                """,
+                Category.CORRECTNESS,
+                5,
+                Severity.ERROR,
+                Implementation(
+                    AutoValueNullnessOverride::class.java,
+                    EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES)
+                )
+            )
+    }
+}
diff --git a/lint-checks/src/main/java/androidx/build/lint/LintUtils.kt b/lint-checks/src/main/java/androidx/build/lint/LintUtils.kt
index 7b48cea..54788dd 100644
--- a/lint-checks/src/main/java/androidx/build/lint/LintUtils.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/LintUtils.kt
@@ -16,9 +16,15 @@
 
 package androidx.build.lint
 
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.model.DefaultLintModelAndroidLibrary
+import com.android.tools.lint.model.DefaultLintModelJavaLibrary
+import com.android.tools.lint.model.LintModelLibrary
+import com.android.tools.lint.model.LintModelMavenName
 import com.intellij.psi.PsiElement
 import com.intellij.psi.PsiMember
 import org.jetbrains.kotlin.asJava.namedUnwrappedElement
+import org.jetbrains.kotlin.konan.file.File
 import org.jetbrains.kotlin.psi.KtNamedDeclaration
 
 /*
@@ -35,3 +41,77 @@
         is KtNamedDeclaration -> element.fqName.toString()
         else -> null
     }
+
+/** Attempts to find the Maven coordinate for the library containing [member]. */
+internal fun JavaContext.findMavenCoordinate(member: PsiMember): LintModelMavenName? {
+    val mavenName =
+        evaluator.getLibrary(member) ?: evaluator.getProject(member)?.mavenCoordinate ?: return null
+
+    // If the lint model is missing a Maven coordinate for this class, try to infer one from the
+    // JAR's owner library. If we fail, return the broken Maven name anyway.
+    if (mavenName == LintModelMavenName.NONE) {
+        return evaluator
+            .findJarPath(member)
+            ?.let { jarPath ->
+                evaluator.findOwnerLibrary(jarPath.replace('/', File.separatorChar))
+            }
+            ?.getMavenNameFromIdentifier() ?: mavenName
+    }
+
+    // If the lint model says the class lives in a "local AAR", try a little bit harder to match
+    // that to an artifact in a real library based on build directory containment.
+    if (mavenName.groupId == "__local_aars__") {
+        val artifactPath = mavenName.artifactId
+
+        // The artifact is being repackaged within this project. Assume that means it's in the same
+        // Maven group.
+        if (artifactPath.startsWith(project.buildModule.buildFolder.path)) {
+            return project.mavenCoordinate
+        }
+
+        val lastIndexOfBuild = artifactPath.lastIndexOf("/build/")
+        if (lastIndexOfBuild < 0) return null
+
+        // Otherwise, try to find a dependency with a matching path and use its Maven group.
+        val path = artifactPath.substring(0, lastIndexOfBuild)
+        return evaluator.dependencies?.getAll()?.findMavenNameWithJarFileInPath(path, mavenName)
+            ?: mavenName
+    }
+
+    return mavenName
+}
+
+/**
+ * Attempts to find the Maven name for the library with at least one JAR file matching the [path].
+ */
+internal fun List<LintModelLibrary>.findMavenNameWithJarFileInPath(
+    path: String,
+    excludeMavenName: LintModelMavenName? = null
+): LintModelMavenName? {
+    return firstNotNullOfOrNull { library ->
+        val resolvedCoordinates =
+            when {
+                library is DefaultLintModelJavaLibrary -> library.resolvedCoordinates
+                library is DefaultLintModelAndroidLibrary -> library.resolvedCoordinates
+                else -> null
+            }
+
+        if (resolvedCoordinates == null || resolvedCoordinates == excludeMavenName) {
+            return@firstNotNullOfOrNull null
+        }
+
+        val hasMatchingJarFile =
+            when {
+                library == excludeMavenName -> emptyList()
+                library is DefaultLintModelJavaLibrary -> library.jarFiles
+                library is DefaultLintModelAndroidLibrary -> library.jarFiles
+                else -> emptyList()
+            }.any { jarFile -> jarFile.path.startsWith(path) }
+
+        if (hasMatchingJarFile) {
+            return@firstNotNullOfOrNull resolvedCoordinates
+        }
+
+        return@firstNotNullOfOrNull null
+    }
+}
diff --git a/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt b/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
index 700e74d..8fa303c 100644
--- a/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/RestrictToDetector.kt
@@ -30,8 +30,6 @@
 import com.android.tools.lint.detector.api.Severity
 import com.android.tools.lint.detector.api.SourceCodeScanner
 import com.android.tools.lint.detector.api.isKotlin
-import com.android.tools.lint.model.DefaultLintModelAndroidLibrary
-import com.android.tools.lint.model.DefaultLintModelJavaLibrary
 import com.android.tools.lint.model.DefaultLintModelMavenName
 import com.android.tools.lint.model.LintModelLibrary
 import com.android.tools.lint.model.LintModelMavenName
@@ -43,7 +41,6 @@
 import com.intellij.psi.PsiMethod
 import com.intellij.psi.impl.compiled.ClsAnnotationImpl
 import com.intellij.psi.util.PsiTypesUtil
-import org.jetbrains.kotlin.konan.file.File
 import org.jetbrains.uast.UAnnotated
 import org.jetbrains.uast.UAnnotation
 import org.jetbrains.uast.UCallExpression
@@ -504,80 +501,6 @@
     }
 }
 
-/** Attempts to find the Maven coordinate for the library containing [member]. */
-private fun JavaContext.findMavenCoordinate(member: PsiMember): LintModelMavenName? {
-    val mavenName =
-        evaluator.getLibrary(member) ?: evaluator.getProject(member)?.mavenCoordinate ?: return null
-
-    // If the lint model is missing a Maven coordinate for this class, try to infer one from the
-    // JAR's owner library. If we fail, return the broken Maven name anyway.
-    if (mavenName == LintModelMavenName.NONE) {
-        return evaluator
-            .findJarPath(member)
-            ?.let { jarPath ->
-                evaluator.findOwnerLibrary(jarPath.replace('/', File.separatorChar))
-            }
-            ?.getMavenNameFromIdentifier() ?: mavenName
-    }
-
-    // If the lint model says the class lives in a "local AAR", try a little bit harder to match
-    // that to an artifact in a real library based on build directory containment.
-    if (mavenName.groupId == "__local_aars__") {
-        val artifactPath = mavenName.artifactId
-
-        // The artifact is being repackaged within this project. Assume that means it's in the same
-        // Maven group.
-        if (artifactPath.startsWith(project.buildModule.buildFolder.path)) {
-            return project.mavenCoordinate
-        }
-
-        val lastIndexOfBuild = artifactPath.lastIndexOf("/build/")
-        if (lastIndexOfBuild < 0) return null
-
-        // Otherwise, try to find a dependency with a matching path and use its Maven group.
-        val path = artifactPath.substring(0, lastIndexOfBuild)
-        return evaluator.dependencies?.getAll()?.findMavenNameWithJarFileInPath(path, mavenName)
-            ?: mavenName
-    }
-
-    return mavenName
-}
-
-/**
- * Attempts to find the Maven name for the library with at least one JAR file matching the [path].
- */
-internal fun List<LintModelLibrary>.findMavenNameWithJarFileInPath(
-    path: String,
-    excludeMavenName: LintModelMavenName? = null
-): LintModelMavenName? {
-    return firstNotNullOfOrNull { library ->
-        val resolvedCoordinates =
-            when {
-                library is DefaultLintModelJavaLibrary -> library.resolvedCoordinates
-                library is DefaultLintModelAndroidLibrary -> library.resolvedCoordinates
-                else -> null
-            }
-
-        if (resolvedCoordinates == null || resolvedCoordinates == excludeMavenName) {
-            return@firstNotNullOfOrNull null
-        }
-
-        val hasMatchingJarFile =
-            when {
-                library == excludeMavenName -> emptyList()
-                library is DefaultLintModelJavaLibrary -> library.jarFiles
-                library is DefaultLintModelAndroidLibrary -> library.jarFiles
-                else -> emptyList()
-            }.any { jarFile -> jarFile.path.startsWith(path) }
-
-        if (hasMatchingJarFile) {
-            return@firstNotNullOfOrNull resolvedCoordinates
-        }
-
-        return@firstNotNullOfOrNull null
-    }
-}
-
 /** Attempts to parse an unversioned Maven name from the library identifier. */
 internal fun LintModelLibrary.getMavenNameFromIdentifier(): LintModelMavenName? {
     val indexOfSentinel = identifier.indexOf(":@@:")
diff --git a/lint-checks/src/test/java/androidx/build/lint/AutoValueNullnessOverrideTest.kt b/lint-checks/src/test/java/androidx/build/lint/AutoValueNullnessOverrideTest.kt
new file mode 100644
index 0000000..32cc1f9
--- /dev/null
+++ b/lint-checks/src/test/java/androidx/build/lint/AutoValueNullnessOverrideTest.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.build.lint
+
+import com.android.tools.lint.checks.infrastructure.ProjectDescription
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class AutoValueNullnessOverrideTest :
+    AbstractLintDetectorTest(
+        useDetector = AutoValueNullnessOverride(),
+        useIssues = listOf(AutoValueNullnessOverride.ISSUE),
+        stubs = arrayOf(autovalueStub, jspecifyNonNullStub, jspecifyNullableStub)
+    ) {
+    @Test
+    fun `No superclass`() {
+        val input =
+            java(
+                """
+                    package test.pkg;
+                    import com.google.auto.value.AutoValue;
+                    import org.jspecify.annotations.Nullable;
+                    @AutoValue
+                    public abstract class Foo {
+                        public abstract @Nullable String getString();
+                    }
+                """
+                    .trimIndent()
+            )
+        check(input).expectClean()
+    }
+
+    @Test
+    fun `Superclass in same library`() {
+        val input =
+            arrayOf(
+                java(
+                    """
+                        package test.pkg;
+                        import com.google.auto.value.AutoValue;
+                        @AutoValue
+                        public abstract class Foo extends ParentClass {
+                        }
+                    """
+                        .trimIndent()
+                ),
+                java(
+                    """
+                        package test.pkg;
+                        import org.jspecify.annotations.Nullable;
+                        public abstract class ParentClass {
+                            public abstract @Nullable String getString();
+                        }
+                    """
+                        .trimIndent()
+                )
+            )
+        check(*input).expectClean()
+    }
+
+    @Test
+    fun `Superclass in different library`() {
+        // Files needs to be set up in project structure for the lint to understand they are from
+        // different libraries
+        val jspecify =
+            project()
+                .files(jspecifyNonNullStub, jspecifyNullableStub)
+                .type(ProjectDescription.Type.LIBRARY)
+
+        val autovalue = project().files(autovalueStub).type(ProjectDescription.Type.LIBRARY)
+
+        val parentLibrary =
+            project()
+                .files(
+                    java(
+                        """
+                            package androidx.example;
+                            import org.jspecify.annotations.NonNull;
+                            import org.jspecify.annotations.Nullable;
+                            public abstract class SuperClass {
+                                public abstract @Nullable String getNullableStringNotOverridden();
+                                public abstract @NonNull String getNonNullStringNotOverridden();
+                                public abstract String getUnannotatedStringNotOverridden();
+
+                                public abstract @Nullable String getNullableStringOverridden();
+
+                                public abstract @Nullable String getNullableStringOverrideNotAbstract();
+                            }
+                        """
+                            .trimIndent(),
+                    ),
+                    gradle(
+                        """
+                            apply plugin: 'com.android.library'
+                            group=androidx.example
+                        """
+                    )
+                )
+                .dependsOn(jspecify)
+                .type(ProjectDescription.Type.LIBRARY)
+
+        val sourceProject =
+            project()
+                .files(
+                    java(
+                        """
+                            package test.pkg;
+                            import com.google.auto.value.AutoValue;
+                            import androidx.example.SuperClass;
+                            @AutoValue
+                            public abstract class Foo extends SuperClass {
+                                @Override
+                                public abstract @Nullable String getNullableStringOverridden();
+
+                                @Override
+                                public abstract @Nullable String getNullableStringOverrideNotAbstract() {
+                                    return null;
+                                }
+                            }
+                        """
+                            .trimIndent(),
+                    ),
+                    gradle(
+                        """
+                            apply plugin: 'com.android.library'
+                            group=test.pkg
+                        """
+                    )
+                )
+                .dependsOn(jspecify)
+                .dependsOn(autovalue)
+                .dependsOn(parentLibrary)
+
+        val expected =
+            """
+                src/main/java/test/pkg/Foo.java:5: Error: Methods need @Nullable overrides for AutoValue: getNullableStringNotOverridden() [AutoValueNullnessOverride]
+                public abstract class Foo extends SuperClass {
+                                      ~~~
+                1 errors, 0 warnings
+            """
+                .trimIndent()
+        val expectedFixDiffs =
+            """
+                Fix for src/main/java/test/pkg/Foo.java line 5: Replace with ...:
+                @@ -6 +6
+                + @Override
+                + public abstract @Nullable String getNullableStringNotOverridden();
+            """
+                .trimIndent()
+
+        lint().projects(sourceProject).run().expect(expected).expectFixDiffs(expectedFixDiffs)
+    }
+
+    companion object {
+        private val autovalueStub =
+            kotlin(
+                """
+                    package com.google.auto.value
+                    annotation class AutoValue
+                """
+                    .trimIndent()
+            )
+        private val jspecifyNullableStub =
+            kotlin(
+                """
+                    package org.jspecify.annotations
+                    @Target(AnnotationTarget.TYPE)
+                    annotation class Nullable
+                """
+                    .trimIndent()
+            )
+        private val jspecifyNonNullStub =
+            kotlin(
+                """
+                    package org.jspecify.annotations
+                    @Target(AnnotationTarget.TYPE)
+                    annotation class NonNull
+                """
+                    .trimIndent()
+            )
+    }
+}
diff --git a/room/room-gradle-plugin/src/main/java/androidx/room/gradle/integration/AndroidPluginIntegration.kt b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/integration/AndroidPluginIntegration.kt
index 8d6691c..3b609ba2 100644
--- a/room/room-gradle-plugin/src/main/java/androidx/room/gradle/integration/AndroidPluginIntegration.kt
+++ b/room/room-gradle-plugin/src/main/java/androidx/room/gradle/integration/AndroidPluginIntegration.kt
@@ -25,9 +25,7 @@
 import androidx.room.gradle.toOptions
 import com.android.build.api.AndroidPluginVersion
 import com.android.build.api.variant.AndroidComponentsExtension
-import com.android.build.api.variant.AndroidTest
 import com.android.build.api.variant.ComponentIdentity
-import com.android.build.api.variant.HasAndroidTest
 import com.android.build.api.variant.HasUnitTest
 import com.google.devtools.ksp.gradle.KspTaskJvm
 import org.gradle.api.Project
@@ -61,7 +59,8 @@
             (variant as? HasUnitTest)?.unitTest?.let {
                 configureAndroidVariant(project, roomExtension, it)
             }
-            (variant as? HasAndroidTest)?.androidTest?.let {
+            @Suppress("DEPRECATION") // usage of HasAndroidTest
+            (variant as? com.android.build.api.variant.HasAndroidTest)?.androidTest?.let {
                 configureAndroidVariant(project, roomExtension, it)
             }
         }
@@ -105,7 +104,8 @@
         // Wires a task that will copy schemas from user configured location to the AGP
         // generated directory to be used as assets inputs of an Android Test app, enabling
         // MigrationTestHelper to automatically pick them up.
-        if (variant is AndroidTest) {
+        @Suppress("DEPRECATION") // Usage of AndroidTest
+        if (variant is com.android.build.api.variant.AndroidTest) {
             variant.sources.assets?.addGeneratedSourceDirectory(
                 project.tasks.register(
                     "copyRoomSchemasToAndroidTestAssets${variant.name.capitalize()}",
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index 1e9d3e6..7378076 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -1,6 +1,44 @@
 // Signature format: 4.0
 package androidx.savedstate {
 
+  public final class SavedStateKt {
+    method public static inline <T> T read(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateReader,? extends T> block);
+    method public static inline <T> T read(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateReader,? extends T> block);
+    method public static androidx.savedstate.SavedState reader(androidx.savedstate.SavedState);
+    method public static inline <T> T write(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,? extends T> block);
+    method public static inline <T> T write(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,? extends T> block);
+    method public static androidx.savedstate.SavedState writer(androidx.savedstate.SavedState);
+  }
+
+  @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 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);
+    method public inline double getDoubleOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Double> defaultValue);
+    method public inline float getFloat(String key);
+    method public inline float getFloatOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Float> defaultValue);
+    method public inline int getInt(String key);
+    method public inline java.util.List<java.lang.Integer> getIntList(String key);
+    method public inline java.util.List<java.lang.Integer> getIntListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<java.lang.Integer>> defaultValue);
+    method public inline int getIntOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Integer> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
+    method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.os.Bundle getSavedState(String key);
+    method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method public android.os.Bundle getSource();
+    method public inline String getString(String key);
+    method public inline java.util.List<java.lang.String> getStringList(String key);
+    method public inline java.util.List<java.lang.String> getStringListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<java.lang.String>> defaultValue);
+    method public inline String getStringOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String> defaultValue);
+    method public inline boolean isEmpty();
+    method public inline int size();
+    property public final android.os.Bundle source;
+  }
+
   public final class SavedStateRegistry {
     method @MainThread public android.os.Bundle? consumeRestoredStateForKey(String key);
     method public androidx.savedstate.SavedStateRegistry.SavedStateProvider? getSavedStateProvider(String key);
@@ -38,6 +76,29 @@
     property public abstract androidx.savedstate.SavedStateRegistry savedStateRegistry;
   }
 
+  @kotlin.jvm.JvmInline public final value class SavedStateWriter {
+    ctor public SavedStateWriter(android.os.Bundle source);
+    method public inline void clear();
+    method public android.os.Bundle getSource();
+    method public inline void putAll(android.os.Bundle values);
+    method public inline void putBoolean(String key, boolean value);
+    method public inline void putDouble(String key, double value);
+    method public inline void putFloat(String key, float value);
+    method public inline void putInt(String key, int value);
+    method public inline void putIntList(String key, java.util.List<java.lang.Integer> values);
+    method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
+    method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline void putString(String key, String value);
+    method public inline void putStringList(String key, java.util.List<java.lang.String> values);
+    method public inline void remove(String key);
+    property public final android.os.Bundle source;
+  }
+
+  public final class SavedState_androidKt {
+    method public static inline android.os.Bundle savedState(optional kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,kotlin.Unit> block);
+  }
+
   public final class ViewKt {
     method @Deprecated public static androidx.savedstate.SavedStateRegistryOwner? findViewTreeSavedStateRegistryOwner(android.view.View view);
   }
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 1e9d3e6..27b0b57 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -1,6 +1,48 @@
 // Signature format: 4.0
 package androidx.savedstate {
 
+  public final class SavedStateKt {
+    method public static inline <T> T read(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateReader,? extends T> block);
+    method public static inline <T> T read(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateReader,? extends T> block);
+    method public static androidx.savedstate.SavedState reader(androidx.savedstate.SavedState);
+    method public static inline <T> T write(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,? extends T> block);
+    method public static inline <T> T write(androidx.savedstate.SavedState, kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,? extends T> block);
+    method public static androidx.savedstate.SavedState writer(androidx.savedstate.SavedState);
+  }
+
+  @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 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);
+    method public inline double getDoubleOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Double> defaultValue);
+    method public inline float getFloat(String key);
+    method public inline float getFloatOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Float> defaultValue);
+    method public inline int getInt(String key);
+    method public inline java.util.List<java.lang.Integer> getIntList(String key);
+    method public inline java.util.List<java.lang.Integer> getIntListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<java.lang.Integer>> defaultValue);
+    method public inline int getIntOrElse(String key, kotlin.jvm.functions.Function0<java.lang.Integer> defaultValue);
+    method @kotlin.PublishedApi internal inline <reified T> java.util.List<T> getListResultOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>?> currentValue);
+    method @kotlin.PublishedApi internal inline <reified T> java.util.List<T> getListResultOrThrow(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>?> currentValue);
+    method public inline <reified T extends android.os.Parcelable> T getParcelable(String key);
+    method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableList(String key);
+    method public inline <reified T extends android.os.Parcelable> java.util.List<T> getParcelableListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<? extends T>> defaultValue);
+    method public inline <reified T extends android.os.Parcelable> T getParcelableOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline android.os.Bundle getSavedState(String key);
+    method public inline android.os.Bundle getSavedStateOrElse(String key, kotlin.jvm.functions.Function0<android.os.Bundle> defaultValue);
+    method @kotlin.PublishedApi internal inline <reified T> T getSingleResultOrElse(String key, kotlin.jvm.functions.Function0<? extends T> defaultValue, kotlin.jvm.functions.Function0<? extends T?> currentValue);
+    method @kotlin.PublishedApi internal inline <reified T> T getSingleResultOrThrow(String key, kotlin.jvm.functions.Function0<? extends T?> currentValue);
+    method public android.os.Bundle getSource();
+    method public inline String getString(String key);
+    method public inline java.util.List<java.lang.String> getStringList(String key);
+    method public inline java.util.List<java.lang.String> getStringListOrElse(String key, kotlin.jvm.functions.Function0<? extends java.util.List<java.lang.String>> defaultValue);
+    method public inline String getStringOrElse(String key, kotlin.jvm.functions.Function0<java.lang.String> defaultValue);
+    method public inline boolean isEmpty();
+    method public inline int size();
+    property public final android.os.Bundle source;
+  }
+
   public final class SavedStateRegistry {
     method @MainThread public android.os.Bundle? consumeRestoredStateForKey(String key);
     method public androidx.savedstate.SavedStateRegistry.SavedStateProvider? getSavedStateProvider(String key);
@@ -38,6 +80,30 @@
     property public abstract androidx.savedstate.SavedStateRegistry savedStateRegistry;
   }
 
+  @kotlin.jvm.JvmInline public final value class SavedStateWriter {
+    ctor public SavedStateWriter(android.os.Bundle source);
+    method public inline void clear();
+    method public android.os.Bundle getSource();
+    method public inline void putAll(android.os.Bundle values);
+    method public inline void putBoolean(String key, boolean value);
+    method public inline void putDouble(String key, double value);
+    method public inline void putFloat(String key, float value);
+    method public inline void putInt(String key, int value);
+    method public inline void putIntList(String key, java.util.List<java.lang.Integer> values);
+    method public inline <reified T extends android.os.Parcelable> void putParcelable(String key, T value);
+    method public inline <reified T extends android.os.Parcelable> void putParcelableList(String key, java.util.List<? extends T> values);
+    method public inline void putSavedState(String key, android.os.Bundle value);
+    method public inline void putString(String key, String value);
+    method public inline void putStringList(String key, java.util.List<java.lang.String> values);
+    method public inline void remove(String key);
+    method @kotlin.PublishedApi internal inline <reified T> java.util.ArrayList<T> toArrayListUnsafe(java.util.Collection<? extends java.lang.Object?>);
+    property public final android.os.Bundle source;
+  }
+
+  public final class SavedState_androidKt {
+    method public static inline android.os.Bundle savedState(optional kotlin.jvm.functions.Function1<? super androidx.savedstate.SavedStateWriter,kotlin.Unit> block);
+  }
+
   public final class ViewKt {
     method @Deprecated public static androidx.savedstate.SavedStateRegistryOwner? findViewTreeSavedStateRegistryOwner(android.view.View view);
   }
@@ -49,3 +115,16 @@
 
 }
 
+package androidx.savedstate.internal {
+
+  @kotlin.PublishedApi internal final class SavedStateUtils {
+    method public inline <reified T> T getValueFromSavedState(String key, kotlin.jvm.functions.Function0<? extends T?> currentValue, kotlin.jvm.functions.Function1<? super java.lang.String,java.lang.Boolean> contains, kotlin.jvm.functions.Function0<? extends T> defaultValue);
+    method public inline Void keyNotFoundError(String key);
+    field public static final boolean DEFAULT_BOOLEAN = false;
+    field public static final double DEFAULT_DOUBLE = 0.0;
+    field public static final float DEFAULT_FLOAT = 0.0f;
+    field public static final int DEFAULT_INT = 0; // 0x0
+  }
+
+}
+
diff --git a/savedstate/savedstate/build.gradle b/savedstate/savedstate/build.gradle
index b863381..86350a0 100644
--- a/savedstate/savedstate/build.gradle
+++ b/savedstate/savedstate/build.gradle
@@ -5,10 +5,9 @@
  * Please use that script when creating a new project, rather than copying an existing project and
  * modifying its settings.
  */
-import androidx.build.PlatformIdentifier
+
 import androidx.build.LibraryType
-import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
-import org.jetbrains.kotlin.konan.target.Family
+import androidx.build.PlatformIdentifier
 
 plugins {
     id("AndroidXPlugin")
@@ -23,11 +22,21 @@
 
     sourceSets {
         commonMain {
-            // TODO(b/334076622)
+            dependencies {
+                api(libs.kotlinStdlib)
+                api("androidx.annotation:annotation:1.8.0")
+                api(projectOrArtifact(":lifecycle:lifecycle-common"))
+            }
         }
 
         commonTest {
-            // TODO(b/334076622)
+            dependencies {
+                implementation(project(":kruth:kruth"))
+                implementation(libs.kotlinTest)
+                implementation(libs.kotlinTestCommon)
+                implementation(libs.kotlinTestAnnotationsCommon)
+                implementation(libs.kotlinCoroutinesTest)
+            }
         }
 
         jvmMain {
@@ -42,12 +51,20 @@
             dependsOn(jvmMain)
             dependencies {
                 api("androidx.annotation:annotation:1.8.1")
+                implementation("androidx.core:core-ktx:1.13.1")
                 implementation("androidx.arch.core:core-common:2.2.0")
                 implementation("androidx.lifecycle:lifecycle-common:2.6.1")
                 api(libs.kotlinStdlib)
             }
         }
 
+        androidUnitTest {
+            dependsOn(jvmTest)
+            dependencies {
+                implementation(libs.robolectric)
+            }
+        }
+
         androidInstrumentedTest {
             dependsOn(jvmTest)
             dependencies {
@@ -60,8 +77,22 @@
             }
         }
 
+        nonAndroidMain {
+            dependsOn(commonMain)
+        }
+
+        nonAndroidTest {
+            dependsOn(commonTest)
+        }
+
         desktopMain {
             dependsOn(jvmMain)
+            dependsOn(nonAndroidMain)
+        }
+
+        desktopTest {
+            dependsOn(jvmTest)
+            dependsOn(nonAndroidTest)
         }
     }
 }
diff --git a/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/RobolectricTest.android.kt b/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/RobolectricTest.android.kt
new file mode 100644
index 0000000..d72a1d9
--- /dev/null
+++ b/savedstate/savedstate/src/androidInstrumentedTest/kotlin/androidx/savedstate/RobolectricTest.android.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+internal actual abstract class RobolectricTest actual constructor()
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/Recreator.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/Recreator.android.kt
index 82bc439..643b555 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/Recreator.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/Recreator.android.kt
@@ -15,7 +15,6 @@
  */
 package androidx.savedstate
 
-import android.os.Bundle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleOwner
@@ -28,15 +27,19 @@
             throw AssertionError("Next event must be ON_CREATE")
         }
         source.lifecycle.removeObserver(this)
-        val bundle: Bundle =
-            owner.savedStateRegistry.consumeRestoredStateForKey(COMPONENT_KEY) ?: return
-        val classes: MutableList<String> =
-            bundle.getStringArrayList(CLASSES_KEY)
-                ?: throw IllegalStateException(
-                    "Bundle with restored state for the component " +
-                        "\"$COMPONENT_KEY\" must contain list of strings by the key " +
-                        "\"$CLASSES_KEY\""
-                )
+
+        val registry = owner.savedStateRegistry
+        val savedState = registry.consumeRestoredStateForKey(COMPONENT_KEY) ?: return
+        val classes =
+            savedState.read {
+                return@read getStringListOrElse(CLASSES_KEY) {
+                    error(
+                        "SavedState with restored state for the component " +
+                            "\"$COMPONENT_KEY\" must contain list of strings by the key " +
+                            "\"$CLASSES_KEY\""
+                    )
+                }
+            }
         for (className: String in classes) {
             reflectiveNew(className)
         }
@@ -79,8 +82,8 @@
             registry.registerSavedStateProvider(COMPONENT_KEY, this)
         }
 
-        override fun saveState(): Bundle {
-            return Bundle().apply { putStringArrayList(CLASSES_KEY, ArrayList(classes)) }
+        override fun saveState(): SavedState = savedState {
+            putStringList(CLASSES_KEY, classes.toList())
         }
 
         fun add(className: String) {
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedState.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedState.android.kt
new file mode 100644
index 0000000..6d51126
--- /dev/null
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedState.android.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+public actual typealias SavedState = android.os.Bundle
+
+public actual inline fun savedState(block: SavedStateWriter.() -> Unit): SavedState =
+    SavedState().apply { write(block) }
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
new file mode 100644
index 0000000..73eea19
--- /dev/null
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateReader.android.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import android.os.Parcelable
+import androidx.core.os.BundleCompat
+import androidx.savedstate.internal.SavedStateUtils
+import androidx.savedstate.internal.SavedStateUtils.getValueFromSavedState
+import androidx.savedstate.internal.SavedStateUtils.keyNotFoundError
+
+@Suppress("NOTHING_TO_INLINE")
+@JvmInline
+actual value class SavedStateReader actual constructor(actual val source: SavedState) {
+
+    actual inline fun getBoolean(key: String): Boolean {
+        return getSingleResultOrThrow(key) {
+            source.getBoolean(key, SavedStateUtils.DEFAULT_BOOLEAN)
+        }
+    }
+
+    actual inline fun getBooleanOrElse(key: String, defaultValue: () -> Boolean): Boolean {
+        return getSingleResultOrElse(key, defaultValue) { source.getBoolean(key) }
+    }
+
+    actual inline fun getDouble(key: String): Double {
+        return getSingleResultOrThrow(key) { source.getDouble(key, SavedStateUtils.DEFAULT_DOUBLE) }
+    }
+
+    actual inline fun getDoubleOrElse(key: String, defaultValue: () -> Double): Double {
+        return getSingleResultOrElse(key, defaultValue) { source.getDouble(key) }
+    }
+
+    actual inline fun getFloat(key: String): Float {
+        return getSingleResultOrThrow(key) { source.getFloat(key, SavedStateUtils.DEFAULT_FLOAT) }
+    }
+
+    actual inline fun getFloatOrElse(key: String, defaultValue: () -> Float): Float {
+        return getSingleResultOrElse(key, defaultValue) { source.getFloat(key) }
+    }
+
+    actual inline fun getInt(key: String): Int {
+        return getSingleResultOrThrow(key) { source.getInt(key, SavedStateUtils.DEFAULT_INT) }
+    }
+
+    actual inline fun getIntOrElse(key: String, defaultValue: () -> Int): Int {
+        return getSingleResultOrElse(key, defaultValue) { source.getInt(key) }
+    }
+
+    /**
+     * Retrieves a [Parcelable] object associated with the specified key. Throws an
+     * [IllegalStateException] if the key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @return The [Parcelable] object associated with the key.
+     * @throws IllegalStateException If the key is not found.
+     */
+    inline fun <reified T : Parcelable> getParcelable(key: String): T {
+        return getSingleResultOrThrow(key) {
+            BundleCompat.getParcelable(source, key, T::class.java)
+        }
+    }
+
+    /**
+     * Retrieves a [Parcelable] object associated with the specified key, or a default value if the
+     * key doesn't exist.
+     *
+     * @param key The key to retrieve the value for.
+     * @param defaultValue A function providing the default [Parcelable] if the key is not found.
+     * @return The [Parcelable] object associated with the key, or the default value if the key is
+     *   not found.
+     */
+    inline fun <reified T : Parcelable> getParcelableOrElse(key: String, defaultValue: () -> T): T {
+        return getSingleResultOrElse(key, defaultValue) {
+            BundleCompat.getParcelable(source, key, T::class.java)
+        }
+    }
+
+    actual inline fun getString(key: String): String {
+        return getSingleResultOrThrow(key) { source.getString(key) }
+    }
+
+    actual inline fun getStringOrElse(key: String, defaultValue: () -> String): String {
+        return getSingleResultOrElse(key, defaultValue) { source.getString(key) }
+    }
+
+    actual inline fun getIntList(key: String): List<Int> {
+        return getListResultOrThrow(key) { source.getIntegerArrayList(key) }
+    }
+
+    actual inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int> {
+        return getListResultOrElse(key, defaultValue) { source.getIntegerArrayList(key) }
+    }
+
+    actual inline fun getStringList(key: String): List<String> {
+        return getListResultOrThrow(key) { source.getStringArrayList(key) }
+    }
+
+    actual inline fun getStringListOrElse(
+        key: String,
+        defaultValue: () -> List<String>
+    ): List<String> {
+        return getListResultOrElse(key, defaultValue) { source.getStringArrayList(key) }
+    }
+
+    /**
+     * Retrieves a [List] of elements of [Parcelable] associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [List] of elements of [Parcelable] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getParcelableList(key: String): List<T> {
+        return getListResultOrThrow(key) {
+            BundleCompat.getParcelableArrayList(source, key, T::class.java)
+        }
+    }
+
+    /**
+     * Retrieves a [List] of elements of [Parcelable] associated with the specified [key], or a
+     * default value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a list of [Parcelable].
+     * @return The list of elements of [Parcelable] associated with the [key], or the default value
+     *   if the [key] is not found.
+     */
+    inline fun <reified T : Parcelable> getParcelableListOrElse(
+        key: String,
+        defaultValue: () -> List<T>
+    ): List<T> {
+        return getListResultOrElse(key, defaultValue) {
+            BundleCompat.getParcelableArrayList(source, key, T::class.java)
+        }
+    }
+
+    actual inline fun getSavedState(key: String): SavedState {
+        return getSingleResultOrThrow(key) { source.getBundle(key) }
+    }
+
+    actual inline fun getSavedStateOrElse(key: String, defaultValue: () -> SavedState): SavedState {
+        return getSingleResultOrElse(key, defaultValue) { source.getBundle(key) }
+    }
+
+    actual inline fun size(): Int = source.size()
+
+    actual inline fun isEmpty(): Boolean = source.isEmpty
+
+    actual inline operator fun contains(key: String): Boolean = source.containsKey(key)
+
+    @PublishedApi
+    internal inline fun <reified T> getSingleResultOrThrow(
+        key: String,
+        currentValue: () -> T?,
+    ): T =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { keyNotFoundError(key) },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getSingleResultOrElse(
+        key: String,
+        defaultValue: () -> T,
+        currentValue: () -> T?,
+    ): T =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { defaultValue() },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getListResultOrThrow(
+        key: String,
+        currentValue: () -> List<T>?,
+    ): List<T> =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { keyNotFoundError(key) },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getListResultOrElse(
+        key: String,
+        defaultValue: () -> List<T>,
+        currentValue: () -> List<T>?,
+    ): List<T> =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { defaultValue() },
+        )
+}
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
index d8427e8..0a7bb5b 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
@@ -15,7 +15,6 @@
  */
 package androidx.savedstate
 
-import android.os.Bundle
 import androidx.annotation.MainThread
 import androidx.arch.core.internal.SafeIterableMap
 import androidx.lifecycle.Lifecycle
@@ -30,7 +29,7 @@
 class SavedStateRegistry internal constructor() {
     private val components = SafeIterableMap<String, SavedStateProvider>()
     private var attached = false
-    private var restoredState: Bundle? = null
+    private var restoredState: SavedState? = null
 
     /**
      * Whether the state was restored after creation and can be safely consumed with
@@ -52,7 +51,7 @@
      * This call clears an internal reference to returned saved state, so if you call it second time
      * in the row it will return `null`.
      *
-     * All unconsumed values will be saved during `onSaveInstanceState(Bundle savedState)`
+     * All unconsumed values will be saved during `onSaveInstanceState(SavedState savedState)`
      *
      * This method can be called after `super.onCreate(savedStateBundle)` of the corresponding
      * component. Calling it before that will result in `IllegalArgumentException`.
@@ -63,20 +62,21 @@
      * @return `S` with the previously saved state or {@code null}
      */
     @MainThread
-    fun consumeRestoredStateForKey(key: String): Bundle? {
+    fun consumeRestoredStateForKey(key: String): SavedState? {
         check(isRestored) {
-            ("You can consumeRestoredStateForKey " +
-                "only after super.onCreate of corresponding component")
+            "You can consumeRestoredStateForKey " +
+                "only after super.onCreate of corresponding component"
         }
-        if (restoredState != null) {
-            val result = restoredState?.getBundle(key)
-            restoredState?.remove(key)
-            if (restoredState?.isEmpty != false) {
-                restoredState = null
-            }
-            return result
+
+        val state = restoredState ?: return null
+
+        val consumed = state.read { if (contains(key)) getSavedState(key) else null }
+        state.write { remove(key) }
+        if (state.read { isEmpty() }) {
+            restoredState = null
         }
-        return null
+
+        return consumed
     }
 
     /**
@@ -96,9 +96,7 @@
     @MainThread
     fun registerSavedStateProvider(key: String, provider: SavedStateProvider) {
         val previous = components.putIfAbsent(key, provider)
-        require(previous == null) {
-            ("SavedStateProvider with the given key is" + " already registered")
-        }
+        require(previous == null) { "SavedStateProvider with the given key is already registered" }
     }
 
     /**
@@ -193,13 +191,16 @@
 
     /** An interface for an owner of this [SavedStateRegistry] to restore saved state. */
     @MainThread
-    internal fun performRestore(savedState: Bundle?) {
+    internal fun performRestore(savedState: SavedState?) {
         check(attached) {
-            ("You must call performAttach() before calling " + "performRestore(Bundle).")
+            "You must call performAttach() before calling performRestore(SavedState)."
         }
         check(!isRestored) { "SavedStateRegistry was already restored." }
-        restoredState = savedState?.getBundle(SAVED_COMPONENTS_KEY)
 
+        restoredState =
+            savedState?.read {
+                if (contains(SAVED_COMPONENTS_KEY)) getSavedState(SAVED_COMPONENTS_KEY) else null
+            }
         isRestored = true
     }
 
@@ -207,22 +208,19 @@
      * An interface for an owner of this [SavedStateRegistry] to perform state saving, it will call
      * all registered providers and merge with unconsumed state.
      *
-     * @param outBundle Bundle in which to place a saved state
+     * @param outBundle SavedState in which to place a saved state
      */
     @MainThread
-    internal fun performSave(outBundle: Bundle) {
-        val components = Bundle()
-        if (restoredState != null) {
-            components.putAll(restoredState)
+    internal fun performSave(outBundle: SavedState) {
+        val inState = savedState {
+            restoredState?.let { putAll(it) }
+            for ((key, provider) in components) {
+                putSavedState(key, provider.saveState())
+            }
         }
-        val it: Iterator<Map.Entry<String, SavedStateProvider>> =
-            this.components.iteratorWithAdditions()
-        while (it.hasNext()) {
-            val (key, value) = it.next()
-            components.putBundle(key, value.saveState())
-        }
-        if (!components.isEmpty) {
-            outBundle.putBundle(SAVED_COMPONENTS_KEY, components)
+
+        if (inState.read { !isEmpty() }) {
+            outBundle.write { putSavedState(SAVED_COMPONENTS_KEY, inState) }
         }
     }
 
@@ -234,7 +232,7 @@
          *
          * Returns `S` with your saved state.
          */
-        fun saveState(): Bundle
+        fun saveState(): SavedState
     }
 
     private companion object {
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryController.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryController.android.kt
index 5b5fd0b..6956e6e 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryController.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryController.android.kt
@@ -15,7 +15,6 @@
  */
 package androidx.savedstate
 
-import android.os.Bundle
 import androidx.annotation.MainThread
 import androidx.lifecycle.Lifecycle
 
@@ -54,7 +53,7 @@
      * @param savedState restored state
      */
     @MainThread
-    fun performRestore(savedState: Bundle?) {
+    fun performRestore(savedState: SavedState?) {
         // To support backward compatibility with libraries that do not explicitly
         // call performAttach(), we make sure that work is done here
         if (!attached) {
@@ -71,10 +70,10 @@
      * An interface for an owner of this [SavedStateRegistry] to perform state saving, it will call
      * all registered providers and merge with unconsumed state.
      *
-     * @param outBundle Bundle in which to place a saved state
+     * @param outBundle SavedState in which to place a saved state
      */
     @MainThread
-    fun performSave(outBundle: Bundle) {
+    fun performSave(outBundle: SavedState) {
         savedStateRegistry.performSave(outBundle)
     }
 
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryOwner.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryOwner.android.kt
index a938673..dee87c7 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryOwner.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistryOwner.android.kt
@@ -30,7 +30,7 @@
  * call [SavedStateRegistryController.performRestore]
  *
  * [SavedStateRegistryController.performRestore] can be called with a nullable if nothing needs to
- * be restored, or with the state Bundle to be restored. performRestore can be called in one of two
+ * be restored, or with the SavedState to be restored. performRestore can be called in one of two
  * places:
  * 1. Directly before the Lifecycle moves to [Lifecycle.State.CREATED]
  * 2. Before [Lifecycle.State.STARTED] is reached, as part of the [LifecycleObserver] that is added
@@ -38,8 +38,8 @@
  *
  * [SavedStateRegistryController.performSave] should be called after owner has been stopped but
  * before it reaches [Lifecycle.State.DESTROYED] state. Hence it should only be called once the
- * owner has received the [Lifecycle.Event.ON_STOP] event. The bundle passed to performSave will be
- * the bundle restored by performRestore.
+ * owner has received the [Lifecycle.Event.ON_STOP] event. The SavedState passed to performSave will
+ * be the SavedState restored by performRestore.
  *
  * @see [ViewTreeSavedStateRegistryOwner]
  */
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
new file mode 100644
index 0000000..2d1c86e
--- /dev/null
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateWriter.android.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import android.os.Parcelable
+
+@Suppress("NOTHING_TO_INLINE")
+@JvmInline
+actual value class SavedStateWriter actual constructor(actual val source: SavedState) {
+
+    actual inline fun putBoolean(key: String, value: Boolean) {
+        source.putBoolean(key, value)
+    }
+
+    actual inline fun putDouble(key: String, value: Double) {
+        source.putDouble(key, value)
+    }
+
+    actual inline fun putFloat(key: String, value: Float) {
+        source.putFloat(key, value)
+    }
+
+    actual inline fun putInt(key: String, value: Int) {
+        source.putInt(key, value)
+    }
+
+    /**
+     * Stores an [Parcelable] value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [Parcelable] value to store.
+     */
+    inline fun <reified T : Parcelable> putParcelable(key: String, value: T) {
+        source.putParcelable(key, value)
+    }
+
+    actual inline fun putString(key: String, value: String) {
+        source.putString(key, value)
+    }
+
+    actual inline fun putIntList(key: String, values: List<Int>) {
+        source.putIntegerArrayList(key, values.toArrayListUnsafe())
+    }
+
+    actual inline fun putStringList(key: String, values: List<String>) {
+        source.putStringArrayList(key, values.toArrayListUnsafe())
+    }
+
+    /**
+     * Stores a list of elements of [Parcelable] associated with the specified key in the
+     * [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The list of elements to store.
+     */
+    inline fun <reified T : Parcelable> putParcelableList(key: String, values: List<T>) {
+        source.putParcelableArrayList(key, values.toArrayListUnsafe())
+    }
+
+    actual inline fun putSavedState(key: String, value: SavedState) {
+        source.putBundle(key, value)
+    }
+
+    actual inline fun putAll(values: SavedState) {
+        source.putAll(values)
+    }
+
+    actual inline fun remove(key: String) {
+        source.remove(key)
+    }
+
+    actual inline fun clear() {
+        source.clear()
+    }
+
+    @Suppress("UNCHECKED_CAST", "ConcreteCollection")
+    @PublishedApi
+    internal inline fun <reified T : Any> Collection<*>.toArrayListUnsafe(): ArrayList<T> {
+        return if (this is ArrayList<*>) this as ArrayList<T> else ArrayList(this as Collection<T>)
+    }
+}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
new file mode 100644
index 0000000..48f76bf
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/ParcelableSavedStateTest.android.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import android.os.Parcel
+import android.os.Parcelable
+import androidx.kruth.assertThat
+import androidx.kruth.assertThrows
+import kotlin.test.Test
+
+internal class ParcelableSavedStateTest : RobolectricTest() {
+
+    @Test
+    fun getParcelable_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelable<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelable_whenNotSet_throws() {
+        assertThrows<IllegalStateException> {
+            savedState().read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelable_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelable<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getParcelableOrElse_whenSet_returns() {
+        val underTest = savedState { putParcelable(KEY_1, PARCELABLE_VALUE_1) }
+        val actual = underTest.read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_2 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getParcelableOrElse(KEY_1) { PARCELABLE_VALUE_1 } }
+
+        assertThat(actual).isEqualTo(PARCELABLE_VALUE_1)
+    }
+
+    @Test
+    fun getParcelableList_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual = underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getList_ofParcelable_whenNotSet_throws() {
+        assertThrows<IllegalStateException> {
+            savedState().read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getListofParcelable_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> {
+            underTest.read { getParcelableList<TestParcelable>(KEY_1) }
+        }
+    }
+
+    @Test
+    fun getListOrElse_ofParcelable_whenSet_returns() {
+        val expected = List(size = 5) { idx -> TestParcelable(idx) }
+
+        val underTest = savedState { putParcelableList(KEY_1, expected) }
+        val actual =
+            underTest.read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getListOrElse_ofParcelable_whenNotSet_returnsElse() {
+        val actual =
+            savedState().read { getParcelableListOrElse<TestParcelable>(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<TestParcelable>())
+    }
+
+    private companion object {
+        const val KEY_1 = "KEY_1"
+        val PARCELABLE_VALUE_1 = TestParcelable(value = Int.MIN_VALUE)
+        val PARCELABLE_VALUE_2 = TestParcelable(value = Int.MAX_VALUE)
+    }
+
+    internal data class TestParcelable(val value: Int) : Parcelable {
+
+        override fun describeContents(): Int = 0
+
+        override fun writeToParcel(dest: Parcel, flags: Int) {
+            dest.writeInt(value)
+        }
+
+        companion object {
+            @Suppress("unused")
+            @JvmField
+            val CREATOR =
+                object : Parcelable.Creator<TestParcelable> {
+                    override fun createFromParcel(source: Parcel) =
+                        TestParcelable(value = source.readInt())
+
+                    override fun newArray(size: Int) = arrayOfNulls<TestParcelable>(size)
+                }
+        }
+    }
+}
diff --git a/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/RobolectricTest.android.kt b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/RobolectricTest.android.kt
new file mode 100644
index 0000000..836b692
--- /dev/null
+++ b/savedstate/savedstate/src/androidUnitTest/kotlin/androidx/savedstate/RobolectricTest.android.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+internal actual abstract class RobolectricTest actual constructor()
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedState.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedState.kt
new file mode 100644
index 0000000..710bdd0
--- /dev/null
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedState.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+/**
+ * An opaque (empty) common type that holds saveable values to be saved and restored by native
+ * platforms that have a concept of System-initiated Process Death.
+ *
+ * That means, the OS will give the chance for the process to keep the state of the application
+ * (normally using a serialization mechanism), and allow the app to restore its state later. That is
+ * commonly referred to as "state restoration".
+ *
+ * required to act as a source input for a [SavedStateReader] or [SavedStateWriter].
+ *
+ * This class represents a container for persistable state data. It is designed to be
+ * platform-agnostic, allowing seamless state saving and restoration across different environments.
+ */
+public expect class SavedState
+
+/** Constructs an empty [SavedState] instance. */
+public expect inline fun savedState(block: SavedStateWriter.() -> Unit = {}): SavedState
+
+/** Creates a new [SavedStateReader] for the [SavedState]. */
+public fun SavedState.reader(): SavedStateReader = SavedStateReader(source = this)
+
+/** Creates a new [SavedStateWriter] for the [SavedState]. */
+public fun SavedState.writer(): SavedStateWriter = SavedStateWriter(source = this)
+
+/**
+ * Calls the specified function [block] with a [SavedStateReader] value as its receiver and returns
+ * the [block] value.
+ *
+ * @param block A lambda function that performs read operations using the [SavedStateReader].
+ * @return The result of the lambda function's execution.
+ * @see [SavedStateReader]
+ * @see [SavedStateWriter]
+ */
+public inline fun <T> SavedState.read(block: SavedStateReader.() -> T): T {
+    return block(reader())
+}
+
+/**
+ * Calls the specified function [block] with a [SavedStateReader] value as its receiver and returns
+ * the [block] value.
+ *
+ * @param block A lambda function that performs read operations using the [SavedStateReader].
+ * @return The result of the lambda function's execution.
+ * @see [SavedStateReader]
+ * @see [SavedStateWriter]
+ */
+public inline fun <T> SavedStateWriter.read(block: SavedStateReader.() -> T): T {
+    return source.read(block)
+}
+
+/**
+ * Calls the specified function [block] with a [SavedStateWriter] value as its receiver and returns
+ * the [block] value.
+ *
+ * @param block A lambda function that performs write operations using the [SavedStateWriter].
+ * @return The result of the lambda function's execution.
+ * @see [SavedStateReader]
+ * @see [SavedStateWriter]
+ */
+public inline fun <T> SavedState.write(block: SavedStateWriter.() -> T): T {
+    return block(writer())
+}
+
+/**
+ * Calls the specified function [block] with a [SavedStateWriter] value as its receiver and returns
+ * the [block] value.
+ *
+ * @param block A lambda function that performs write operations using the [SavedStateWriter].
+ * @return The result of the lambda function's execution.
+ * @see [SavedStateReader]
+ * @see [SavedStateWriter]
+ */
+public inline fun <T> SavedStateReader.write(block: SavedStateWriter.() -> T): T {
+    return source.write(block)
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
new file mode 100644
index 0000000..b0c4477
--- /dev/null
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateReader.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+/**
+ * An inline class that encapsulates an opaque [SavedState], and provides an API for reading the
+ * platform specific state.
+ *
+ * @see SavedState.read
+ */
+@JvmInline
+public expect value class SavedStateReader
+internal constructor(
+    @PublishedApi internal val source: SavedState,
+) {
+
+    /**
+     * Retrieves a [Boolean] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Boolean] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getBoolean(key: String): Boolean
+
+    /**
+     * Retrieves a [Boolean] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [Boolean] value associated with the [key], or the default value if the [key] is
+     *   not found.
+     */
+    public inline fun getBooleanOrElse(key: String, defaultValue: () -> Boolean): Boolean
+
+    /**
+     * Retrieves a [Double] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Double] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getDouble(key: String): Double
+
+    /**
+     * Retrieves a [Double] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [Double] value associated with the [key], or the default value if the [key] is
+     *   not found.
+     */
+    public inline fun getDoubleOrElse(key: String, defaultValue: () -> Double): Double
+
+    /**
+     * Retrieves a [Float] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Float] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getFloat(key: String): Float
+
+    /**
+     * Retrieves a [Float] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [Float] value associated with the [key], or the default value if the [key] is not
+     *   found.
+     */
+    public inline fun getFloatOrElse(key: String, defaultValue: () -> Float): Float
+
+    /**
+     * Retrieves an [Int] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [Int] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getInt(key: String): Int
+
+    /**
+     * Retrieves an [Int] value associated with the specified [key], or a default value if the [key]
+     * doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [Int] value associated with the [key], or the default value if the [key] is not
+     *   found.
+     */
+    public inline fun getIntOrElse(key: String, defaultValue: () -> Int): Int
+
+    /**
+     * Retrieves a [String] value associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [String] value associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getString(key: String): String
+
+    /**
+     * Retrieves a [String] value associated with the specified [key], or a default value if the
+     * [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found.
+     * @return The [String] value associated with the [key], or the default value if the [key] is
+     *   not found.
+     */
+    public inline fun getStringOrElse(key: String, defaultValue: () -> String): String
+
+    /**
+     * Retrieves a [List] of elements of [Int] associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [List] of elements of [Int] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getIntList(key: String): List<Int>
+
+    /**
+     * Retrieves a [List] of elements of [Int] associated with the specified [key], or a default
+     * value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a list of [Int].
+     * @return The list of elements of [Int] associated with the [key], or the default value if the
+     *   [key] is not found.
+     */
+    public inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int>
+
+    /**
+     * Retrieves a [List] of elements of [String] associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [List] of elements of [String] associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getStringList(key: String): List<String>
+
+    /**
+     * Retrieves a [List] of elements of [String] associated with the specified [key], or a default
+     * value if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default value if the [key] is not found or the
+     *   retrieved value is not a list of [String].
+     * @return The list of elements of [String] associated with the [key], or the default value if
+     *   the [key] is not found.
+     */
+    public inline fun getStringListOrElse(
+        key: String,
+        defaultValue: () -> List<String>
+    ): List<String>
+
+    /**
+     * Retrieves a [SavedState] object associated with the specified [key]. Throws an
+     * [IllegalStateException] if the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @return The [SavedState] object associated with the [key].
+     * @throws IllegalStateException If the [key] is not found.
+     */
+    public inline fun getSavedState(key: String): SavedState
+
+    /**
+     * Retrieves a [SavedState] object associated with the specified [key], or a default value if
+     * the [key] doesn't exist.
+     *
+     * @param key The [key] to retrieve the value for.
+     * @param defaultValue A function providing the default [SavedState] if the [key] is not found.
+     * @return The [SavedState] object associated with the [key], or the default value if the [key]
+     *   is not found.
+     */
+    public inline fun getSavedStateOrElse(key: String, defaultValue: () -> SavedState): SavedState
+
+    /**
+     * Returns the number of key-value pairs in the [SavedState].
+     *
+     * @return The size of the [SavedState].
+     */
+    public inline fun size(): Int
+
+    /**
+     * Checks if the [SavedState] is empty (contains no key-value pairs).
+     *
+     * @return `true` if the [SavedState] is empty, `false` otherwise.
+     */
+    public inline fun isEmpty(): Boolean
+
+    /**
+     * Checks if the [SavedState] contains the specified [key].
+     *
+     * @param key The [key] to check for.
+     * @return `true` if the [SavedState] contains the [key], `false` otherwise.
+     */
+    public inline operator fun contains(key: String): Boolean
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
new file mode 100644
index 0000000..67c312c
--- /dev/null
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateWriter.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+/**
+ * An inline class that encapsulates an opaque [SavedState], and provides an API for writing the
+ * platform specific state.
+ *
+ * @see SavedState.write
+ */
+@JvmInline
+public expect value class SavedStateWriter
+internal constructor(
+    @PublishedApi internal val source: SavedState,
+) {
+
+    /**
+     * Stores a boolean value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The boolean value to store.
+     */
+    public inline fun putBoolean(key: String, value: Boolean)
+
+    /**
+     * Stores a double value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The double value to store.
+     */
+    public inline fun putDouble(key: String, value: Double)
+
+    /**
+     * Stores a float value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The float value to store.
+     */
+    public inline fun putFloat(key: String, value: Float)
+
+    /**
+     * Stores an int value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The int value to store.
+     */
+    public inline fun putInt(key: String, value: Int)
+
+    /**
+     * Stores a string value associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The string value to store.
+     */
+    public inline fun putString(key: String, value: String)
+
+    /**
+     * Stores a list of elements of [Int] associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The list of elements to store.
+     */
+    public inline fun putIntList(key: String, values: List<Int>)
+
+    /**
+     * Stores a list of elements of [String] associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param values The list of elements to store.
+     */
+    public inline fun putStringList(key: String, values: List<String>)
+
+    /**
+     * Stores a [SavedState] object associated with the specified key in the [SavedState].
+     *
+     * @param key The key to associate the value with.
+     * @param value The [SavedState] object to store
+     */
+    public inline fun putSavedState(key: String, value: SavedState)
+
+    /**
+     * Stores all key-value pairs from the provided [SavedState] into this [SavedState].
+     *
+     * @param values The [SavedState] containing the key-value pairs to add.
+     */
+    public inline fun putAll(values: SavedState)
+
+    /**
+     * Removes the value associated with the specified key from the [SavedState].
+     *
+     * @param key The key to remove.
+     */
+    public inline fun remove(key: String)
+
+    /** Removes all key-value pairs from the [SavedState]. */
+    public inline fun clear()
+}
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateUtils.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateUtils.kt
new file mode 100644
index 0000000..8cebe5e9
--- /dev/null
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateUtils.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate.internal
+
+@PublishedApi
+internal object SavedStateUtils {
+
+    const val DEFAULT_BOOLEAN = false
+    const val DEFAULT_FLOAT = 0f
+    const val DEFAULT_DOUBLE = 0.0
+    const val DEFAULT_INT = 0
+
+    @Suppress("NOTHING_TO_INLINE")
+    inline fun keyNotFoundError(key: String): Nothing =
+        error("Saved state key '$key' was not found")
+
+    inline fun <reified T> getValueFromSavedState(
+        key: String,
+        currentValue: () -> T?,
+        contains: (key: String) -> Boolean,
+        defaultValue: () -> T,
+    ): T {
+        return if (contains(key)) {
+            currentValue() ?: defaultValue()
+        } else {
+            defaultValue()
+        }
+    }
+}
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/RobolectricTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/RobolectricTest.kt
new file mode 100644
index 0000000..786c5fb
--- /dev/null
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/RobolectricTest.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+internal expect abstract class RobolectricTest()
diff --git a/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
new file mode 100644
index 0000000..1034b38
--- /dev/null
+++ b/savedstate/savedstate/src/commonTest/kotlin/androidx/savedstate/SavedStateTest.kt
@@ -0,0 +1,410 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import androidx.kruth.assertThat
+import androidx.kruth.assertThrows
+import androidx.savedstate.internal.SavedStateUtils
+import kotlin.test.Test
+
+internal class SavedStateTest : RobolectricTest() {
+
+    @Test
+    fun contains_whenHasKey_returnsTrue() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThat(underTest.read { contains(KEY_1) }).isTrue()
+    }
+
+    @Test
+    fun contains_whenDoesNotHaveKey_returnsFalse() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThat(underTest.read { contains(KEY_2) }).isFalse()
+    }
+
+    @Test
+    fun isEmpty_whenEmpty_returnTrue() {
+        val underTest = savedState()
+
+        assertThat(underTest.read { isEmpty() }).isTrue()
+    }
+
+    @Test
+    fun isEmpty_whenNotEmpty_returnFalse() {
+        val underTest = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+
+        assertThat(underTest.read { isEmpty() }).isFalse()
+    }
+
+    @Test
+    fun size() {
+        val underTest = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+
+        assertThat(underTest.read { size() }).isEqualTo(expected = 2)
+    }
+
+    @Test
+    fun remove() {
+        val underTest = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+
+        underTest.read {
+            assertThat(contains(KEY_1)).isTrue()
+            assertThat(contains(KEY_2)).isTrue()
+        }
+
+        underTest.write { remove(KEY_1) }
+
+        underTest.read {
+            assertThat(contains(KEY_1)).isFalse()
+            assertThat(contains(KEY_2)).isTrue()
+        }
+    }
+
+    @Test
+    fun clear() {
+        val underTest = savedState {
+            putInt(KEY_1, Int.MAX_VALUE)
+            putInt(KEY_2, Int.MAX_VALUE)
+        }
+        underTest.write { clear() }
+
+        assertThat(underTest.read { isEmpty() }).isTrue()
+    }
+
+    // region getters and setters
+    @Test
+    fun getBoolean_whenSet_returns() {
+        val expected = true
+
+        val underTest = savedState { putBoolean(KEY_1, expected) }
+        val actual = underTest.read { getBoolean(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getBoolean_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getBoolean(KEY_1) } }
+    }
+
+    @Test
+    fun getBoolean_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getBoolean(KEY_1) }
+
+        assertThat(actual).isEqualTo(SavedStateUtils.DEFAULT_BOOLEAN)
+    }
+
+    @Test
+    fun getBooleanOrElse_whenSet_returns() {
+        val expected = true
+
+        val underTest = savedState { putBoolean(KEY_1, expected) }
+        val actual = underTest.read { getBooleanOrElse(KEY_1) { false } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getBooleanOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getBooleanOrElse(KEY_1) { false } }
+
+        assertThat(actual).isFalse()
+    }
+
+    @Test
+    fun getDouble_whenSet_returns() {
+        val underTest = savedState { putDouble(KEY_1, Double.MAX_VALUE) }
+        val actual = underTest.read { getDouble(KEY_1) }
+
+        assertThat(actual).isEqualTo(Double.MAX_VALUE)
+    }
+
+    @Test
+    fun getDouble_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getDouble(KEY_1) } }
+    }
+
+    @Test
+    fun getDouble_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getDouble(KEY_1) }
+
+        assertThat(actual).isEqualTo(SavedStateUtils.DEFAULT_DOUBLE)
+    }
+
+    @Test
+    fun getDoubleOrElse_whenSet_returns() {
+        val underTest = savedState { putDouble(KEY_1, Double.MAX_VALUE) }
+        val actual = underTest.read { getDoubleOrElse(KEY_1) { Double.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Double.MAX_VALUE)
+    }
+
+    @Test
+    fun getDoubleOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getDoubleOrElse(KEY_1) { Double.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Double.MIN_VALUE)
+    }
+
+    @Test
+    fun getFloat_whenSet_returns() {
+        val underTest = savedState { putFloat(KEY_1, Float.MAX_VALUE) }
+        val actual = underTest.read { getFloat(KEY_1) }
+
+        assertThat(actual).isEqualTo(Float.MAX_VALUE)
+    }
+
+    @Test
+    fun getFloat_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getFloat(KEY_1) } }
+    }
+
+    @Test
+    fun getFloat_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getFloat(KEY_1) }
+
+        assertThat(actual).isEqualTo(SavedStateUtils.DEFAULT_FLOAT)
+    }
+
+    @Test
+    fun getFloatOrElse_whenSet_returns() {
+        val underTest = savedState { putFloat(KEY_1, Float.MAX_VALUE) }
+        val actual = underTest.read { getFloatOrElse(KEY_1) { Float.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Float.MAX_VALUE)
+    }
+
+    @Test
+    fun getFloatOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getFloatOrElse(KEY_1) { Float.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Float.MIN_VALUE)
+    }
+
+    @Test
+    fun getInt_whenSet_returns() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getInt(KEY_1) }
+
+        assertThat(actual).isEqualTo(Int.MAX_VALUE)
+    }
+
+    @Test
+    fun getInt_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getInt(KEY_1) } }
+    }
+
+    @Test
+    fun getInt_whenSet_differentType_returnsDefault() {
+        val underTest = savedState { putBoolean(KEY_1, false) }
+        val actual = underTest.read { getInt(KEY_1) }
+
+        assertThat(actual).isEqualTo(SavedStateUtils.DEFAULT_INT)
+    }
+
+    @Test
+    fun getIntOrElse_whenSet_returns() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+        val actual = underTest.read { getIntOrElse(KEY_1) { Int.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Int.MAX_VALUE)
+    }
+
+    @Test
+    fun getIntOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getIntOrElse(KEY_1) { Int.MIN_VALUE } }
+
+        assertThat(actual).isEqualTo(Int.MIN_VALUE)
+    }
+
+    @Test
+    fun getString_whenSet_returns() {
+        val underTest = savedState { putString(KEY_1, STRING_VALUE) }
+        val actual = underTest.read { getString(KEY_1) }
+
+        assertThat(actual).isEqualTo(STRING_VALUE)
+    }
+
+    @Test
+    fun getString_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getString(KEY_1) } }
+    }
+
+    @Test
+    fun getString_whenSet_differentType_throws() {
+        val underTest = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        assertThrows<IllegalStateException> { underTest.read { getString(KEY_1) } }
+    }
+
+    @Test
+    fun getStringOrElse_whenSet_returns() {
+        val underTest = savedState { putString(KEY_1, STRING_VALUE) }
+        val actual = underTest.read { getString(KEY_1) }
+
+        assertThat(actual).isEqualTo(STRING_VALUE)
+    }
+
+    @Test
+    fun getStringOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getStringOrElse(KEY_1) { STRING_VALUE } }
+
+        assertThat(actual).isEqualTo(STRING_VALUE)
+    }
+
+    @Test
+    fun getIntList_whenSet_returns() {
+        val expected = List(size = 5) { idx -> idx }
+
+        val underTest = savedState { putIntList(KEY_1, expected) }
+        val actual = underTest.read { getIntList(KEY_1) }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun getIntList_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getIntList(KEY_1) } }
+    }
+
+    @Test
+    fun getIntList_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getIntList(KEY_1) } }
+    }
+
+    @Test
+    fun getIntListOrElse_whenSet_returns() {
+        val underTest = savedState { putIntList(KEY_1, LIST_INT_VALUE) }
+        val actual = underTest.read { getIntListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(LIST_INT_VALUE)
+    }
+
+    @Test
+    fun getIntListOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getIntListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<Int>())
+    }
+
+    @Test
+    fun getStringList_whenSet_returns() {
+        val underTest = savedState { putStringList(KEY_1, LIST_STRING_VALUE) }
+        val actual = underTest.read { getStringList(KEY_1) }
+
+        assertThat(actual).isEqualTo(LIST_STRING_VALUE)
+    }
+
+    @Test
+    fun getStringList_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getStringList(KEY_1) } }
+    }
+
+    @Test
+    fun getStringList_whenSet_differentType_throws() {
+        val expected = Int.MAX_VALUE
+
+        val underTest = savedState { putInt(KEY_1, expected) }
+
+        assertThrows<IllegalStateException> { underTest.read { getStringList(KEY_1) } }
+    }
+
+    @Test
+    fun getStringListOrElse_whenSet_returns() {
+        val underTest = savedState { putStringList(KEY_1, LIST_STRING_VALUE) }
+        val actual = underTest.read { getStringListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(LIST_STRING_VALUE)
+    }
+
+    @Test
+    fun getStringListOrElse_whenNotSet_returnsElse() {
+        val actual = savedState().read { getStringListOrElse(KEY_1) { emptyList() } }
+
+        assertThat(actual).isEqualTo(emptyList<String>())
+    }
+
+    @Test
+    fun getSavedState_whenSet_returns() {
+        val underTest = savedState { putSavedState(KEY_1, SAVED_STATE_VALUE) }
+        val actual = underTest.read { getSavedState(KEY_1) }
+
+        assertThat(actual).isEqualTo(SAVED_STATE_VALUE)
+    }
+
+    @Test
+    fun getSavedState_whenNotSet_throws() {
+        assertThrows<IllegalStateException> { savedState().read { getSavedState(KEY_1) } }
+    }
+
+    @Test
+    fun getSavedStateOrElse_whenSet_returns() {
+        val underTest = savedState { putSavedState(KEY_1, SAVED_STATE_VALUE) }
+        val actual = underTest.read { getSavedStateOrElse(KEY_1) { savedState() } }
+
+        assertThat(actual).isEqualTo(SAVED_STATE_VALUE)
+    }
+
+    @Test
+    fun getSavedStateOrElse_whenNotSet_returnsElse() {
+        val expected = savedState()
+
+        val actual = savedState().read { getSavedStateOrElse(KEY_1) { expected } }
+
+        assertThat(actual).isEqualTo(expected)
+    }
+
+    @Test
+    fun putAll() {
+        val previousState = savedState { putInt(KEY_1, Int.MAX_VALUE) }
+
+        val underTest = savedState { putAll(previousState) }
+        val actual = underTest.read { getInt(KEY_1) }
+
+        assertThat(actual).isEqualTo(Int.MAX_VALUE)
+    }
+
+    // endregion
+
+    private companion object {
+        const val KEY_1 = "KEY_1"
+        const val KEY_2 = "KEY_2"
+        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" }
+        val SET_INT_VALUE = LIST_INT_VALUE.toSet()
+        val SET_STRING_VALUE = LIST_STRING_VALUE.toSet()
+        val SAVED_STATE_VALUE = savedState()
+    }
+}
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedState.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedState.nonAndroid.kt
new file mode 100644
index 0000000..127e516
--- /dev/null
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedState.nonAndroid.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+public actual class SavedState
+@PublishedApi
+internal constructor(@PublishedApi internal val map: MutableMap<String, Any> = mutableMapOf())
+
+actual inline fun savedState(block: SavedStateWriter.() -> Unit): SavedState =
+    SavedState().apply { write(block) }
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
new file mode 100644
index 0000000..aabc6e8
--- /dev/null
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateReader.nonAndroid.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+import androidx.savedstate.internal.SavedStateUtils
+import androidx.savedstate.internal.SavedStateUtils.getValueFromSavedState
+import androidx.savedstate.internal.SavedStateUtils.keyNotFoundError
+
+@Suppress("NOTHING_TO_INLINE")
+@JvmInline
+actual value class SavedStateReader actual constructor(actual val source: SavedState) {
+
+    actual inline fun getBoolean(key: String): Boolean =
+        getSingleResultOrThrow(key) {
+            source.map[key] as? Boolean ?: SavedStateUtils.DEFAULT_BOOLEAN
+        }
+
+    actual inline fun getBooleanOrElse(key: String, defaultValue: () -> Boolean): Boolean =
+        getSingleResultOrElse(key, defaultValue) {
+            source.map[key] as? Boolean ?: SavedStateUtils.DEFAULT_BOOLEAN
+        }
+
+    actual inline fun getDouble(key: String): Double =
+        getSingleResultOrThrow(key) { source.map[key] as? Double ?: SavedStateUtils.DEFAULT_DOUBLE }
+
+    actual inline fun getDoubleOrElse(key: String, defaultValue: () -> Double): Double =
+        getSingleResultOrElse(key, defaultValue) {
+            source.map[key] as? Double ?: SavedStateUtils.DEFAULT_DOUBLE
+        }
+
+    actual inline fun getFloat(key: String): Float =
+        getSingleResultOrThrow(key) { source.map[key] as? Float ?: SavedStateUtils.DEFAULT_FLOAT }
+
+    actual inline fun getFloatOrElse(key: String, defaultValue: () -> Float): Float =
+        getSingleResultOrElse(key, defaultValue) {
+            source.map[key] as? Float ?: SavedStateUtils.DEFAULT_FLOAT
+        }
+
+    actual inline fun getInt(key: String): Int =
+        getSingleResultOrThrow(key) { source.map[key] as? Int ?: SavedStateUtils.DEFAULT_INT }
+
+    actual inline fun getIntOrElse(key: String, defaultValue: () -> Int): Int =
+        getSingleResultOrElse(key, defaultValue) {
+            source.map[key] as? Int ?: SavedStateUtils.DEFAULT_INT
+        }
+
+    actual inline fun getString(key: String): String =
+        getSingleResultOrThrow(key) { source.map[key] as? String }
+
+    actual inline fun getStringOrElse(key: String, defaultValue: () -> String): String =
+        getSingleResultOrElse(key, defaultValue) { source.map[key] as? String }
+
+    @Suppress("UNCHECKED_CAST")
+    actual inline fun getIntList(key: String): List<Int> {
+        return getListResultOrThrow(key) { source.map[key] as? List<Int> }
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    actual inline fun getIntListOrElse(key: String, defaultValue: () -> List<Int>): List<Int> {
+        return getListResultOrElse(key, defaultValue) { source.map[key] as? List<Int> }
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    actual inline fun getStringList(key: String): List<String> {
+        return getListResultOrThrow(key) { source.map[key] as? List<String> }
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    actual inline fun getStringListOrElse(
+        key: String,
+        defaultValue: () -> List<String>
+    ): List<String> {
+        return getListResultOrElse(key, defaultValue) { source.map[key] as? List<String> }
+    }
+
+    actual inline fun getSavedState(key: String): SavedState =
+        getSingleResultOrThrow(key) { source.map[key] as? SavedState }
+
+    actual inline fun getSavedStateOrElse(key: String, defaultValue: () -> SavedState): SavedState =
+        getSingleResultOrElse(key, defaultValue) { source.map[key] as? SavedState }
+
+    actual inline fun size(): Int {
+        return source.map.size
+    }
+
+    actual inline fun isEmpty(): Boolean {
+        return source.map.isEmpty()
+    }
+
+    actual inline operator fun contains(key: String): Boolean {
+        return source.map.containsKey(key)
+    }
+
+    @PublishedApi
+    internal inline fun <reified T> getSingleResultOrThrow(
+        key: String,
+        currentValue: () -> T?,
+    ): T =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.map.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { keyNotFoundError(key) },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getSingleResultOrElse(
+        key: String,
+        defaultValue: () -> T,
+        currentValue: () -> T?,
+    ): T =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.map.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { defaultValue() },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getListResultOrThrow(
+        key: String,
+        currentValue: () -> List<T>?,
+    ): List<T> =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.map.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { keyNotFoundError(key) },
+        )
+
+    @PublishedApi
+    internal inline fun <reified T> getListResultOrElse(
+        key: String,
+        defaultValue: () -> List<T>,
+        currentValue: () -> List<T>?,
+    ): List<T> =
+        getValueFromSavedState(
+            key = key,
+            contains = { source.map.containsKey(key) },
+            currentValue = { currentValue() },
+            defaultValue = { defaultValue() },
+        )
+}
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
new file mode 100644
index 0000000..dbc41dd
--- /dev/null
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateWriter.nonAndroid.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+@Suppress("NOTHING_TO_INLINE")
+@JvmInline
+actual value class SavedStateWriter actual constructor(actual val source: SavedState) {
+
+    actual inline fun putBoolean(key: String, value: Boolean) {
+        source.map[key] = value
+    }
+
+    actual inline fun putDouble(key: String, value: Double) {
+        source.map[key] = value
+    }
+
+    actual inline fun putFloat(key: String, value: Float) {
+        source.map[key] = value
+    }
+
+    actual inline fun putInt(key: String, value: Int) {
+        source.map[key] = value
+    }
+
+    actual inline fun putString(key: String, value: String) {
+        source.map[key] = value
+    }
+
+    actual inline fun putSavedState(key: String, value: SavedState) {
+        source.map[key] = value
+    }
+
+    actual inline fun putIntList(key: String, values: List<Int>) {
+        source.map[key] = values
+    }
+
+    actual inline fun putStringList(key: String, values: List<String>) {
+        source.map[key] = values
+    }
+
+    actual inline fun putAll(values: SavedState) {
+        source.map.putAll(values.map)
+    }
+
+    actual inline fun remove(key: String) {
+        source.map.remove(key)
+    }
+
+    actual inline fun clear() {
+        source.map.clear()
+    }
+}
diff --git a/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/RobolectricTest.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/RobolectricTest.nonAndroid.kt
new file mode 100644
index 0000000..d72a1d9
--- /dev/null
+++ b/savedstate/savedstate/src/nonAndroidTest/kotlin/androidx/savedstate/RobolectricTest.nonAndroid.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.savedstate
+
+internal actual abstract class RobolectricTest actual constructor()
diff --git a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
index f63dae9..dd6a03a 100644
--- a/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
+++ b/security/security-state/src/main/java/androidx/security/state/SecurityPatchState.kt
@@ -393,7 +393,7 @@
         }
 
         val cvePattern = Pattern.compile("CVE-\\d{4}-\\d{4,}")
-        val asbPattern = Pattern.compile("ASB-A-\\d{4,}")
+        val asbPattern = Pattern.compile("(ASB|PUB)-A-\\d{4,}")
 
         result.vulnerabilities.values.flatten().forEach { group ->
             group.cveIdentifiers.forEach { cve ->
@@ -407,7 +407,7 @@
             group.asbIdentifiers.forEach { asb ->
                 if (!asbPattern.matcher(asb).matches()) {
                     throw IllegalArgumentException(
-                        "ASB identifier does not match the required format (ASB-A-XXXX): $asb"
+                        "ASB identifier $asb does not match the required format: $asbPattern"
                     )
                 }
             }
diff --git a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
index ba905ac..0df63be 100644
--- a/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
+++ b/security/security-state/src/test/java/androidx/security/state/SecurityPatchStateTest.kt
@@ -175,6 +175,12 @@
                         "asb_identifiers": ["ASB-A-2020"],
                         "severity": "high",
                         "components": ["system", "vendor"]
+                    }],
+                    "2020-05-01": [{
+                        "cve_identifiers": ["CVE-2020-5678"],
+                        "asb_identifiers": ["PUB-A-5678"],
+                        "severity": "moderate",
+                        "components": ["system"]
                     }]
                 },
                 "extra_field": { test: 12345 },
@@ -192,7 +198,31 @@
             )
 
         assertEquals(1, fixes[SecurityPatchState.Severity.HIGH]?.size)
+        assertEquals(1, fixes[SecurityPatchState.Severity.MODERATE]?.size)
         assertEquals(setOf("CVE-2020-1234"), fixes[SecurityPatchState.Severity.HIGH])
+        assertEquals(setOf("CVE-2020-5678"), fixes[SecurityPatchState.Severity.MODERATE])
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun testParseVulnerabilityReport_invalidAsb_throwsIllegalArgumentException() {
+        val jsonString =
+            """
+            {
+                "vulnerabilities": {
+                    "2020-01-01": [{
+                        "cve_identifiers": ["CVE-2020-1234"],
+                        "asb_identifiers": ["ASB-123"],
+                        "severity": "high",
+                        "components": ["system", "vendor"]
+                    }]
+                },
+                "extra_field": { test: 12345 },
+                "kernel_lts_versions": {
+                    "2020-01-01": ["4.14"]
+                }
+            }
+        """
+        securityState.loadVulnerabilityReport(jsonString)
     }
 
     @Test(expected = IllegalArgumentException::class)
diff --git a/transition/transition/src/androidTest/java/androidx/transition/FragmentTransitionSeekingTest.kt b/transition/transition/src/androidTest/java/androidx/transition/FragmentTransitionSeekingTest.kt
index 05a1e4b..649b4ba 100644
--- a/transition/transition/src/androidTest/java/androidx/transition/FragmentTransitionSeekingTest.kt
+++ b/transition/transition/src/androidTest/java/androidx/transition/FragmentTransitionSeekingTest.kt
@@ -126,6 +126,201 @@
     }
 
     @Test
+    fun multipleReplaceOperationFastSystemBack() {
+        withUse(ActivityScenario.launch(FragmentTransitionTestActivity::class.java)) {
+            val fm1 = withActivity { supportFragmentManager }
+
+            val fragment1 = TransitionFragment(R.layout.scene1)
+            fragment1.setReenterTransition(Fade().apply { duration = 300 })
+            fragment1.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment1, "1")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            val fragment2 = TransitionFragment(R.layout.scene1)
+            fragment2.setReenterTransition(Fade().apply { duration = 300 })
+            fragment2.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment2, "2")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment1.waitForTransition()
+            fragment2.waitForTransition()
+
+            val fragment3 = TransitionFragment(R.layout.scene1)
+            fragment3.setReenterTransition(Fade().apply { duration = 300 })
+            fragment3.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment3, "3")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment2.waitForTransition()
+            fragment3.waitForTransition()
+
+            val fragment4 = TransitionFragment(R.layout.scene1)
+            fragment4.setReenterTransition(Fade().apply { duration = 300 })
+            fragment4.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment4, "3")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment3.waitForTransition()
+            fragment4.waitForTransition()
+
+            val dispatcher = withActivity { onBackPressedDispatcher }
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            fragment1.waitForNoTransition()
+
+            assertThat(fragment2.isAdded).isFalse()
+            assertThat(fm1.findFragmentByTag("2")).isEqualTo(null)
+
+            // Make sure the original fragment was correctly readded to the container
+            assertThat(fragment1.requireView().parent).isNotNull()
+        }
+    }
+
+    @Test
+    fun multipleReplaceOperationFastGestureBack() {
+        withUse(ActivityScenario.launch(FragmentTransitionTestActivity::class.java)) {
+            val fm1 = withActivity { supportFragmentManager }
+
+            val fragment1 = TransitionFragment(R.layout.scene1)
+            fragment1.setReenterTransition(Fade().apply { duration = 300 })
+            fragment1.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment1, "1")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            val fragment2 = TransitionFragment(R.layout.scene1)
+            fragment2.setReenterTransition(Fade().apply { duration = 300 })
+            fragment2.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment2, "2")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment1.waitForTransition()
+            fragment2.waitForTransition()
+
+            val fragment3 = TransitionFragment(R.layout.scene1)
+            fragment3.setReenterTransition(Fade().apply { duration = 300 })
+            fragment3.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment3, "3")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment2.waitForTransition()
+            fragment3.waitForTransition()
+
+            val fragment4 = TransitionFragment(R.layout.scene1)
+            fragment4.setReenterTransition(Fade().apply { duration = 300 })
+            fragment4.setReturnTransition(Fade().apply { duration = 300 })
+
+            fm1.beginTransaction()
+                .replace(R.id.fragmentContainer, fragment4, "3")
+                .setReorderingAllowed(true)
+                .addToBackStack(null)
+                .commit()
+            waitForExecution()
+
+            fragment3.waitForTransition()
+            fragment4.waitForTransition()
+
+            val dispatcher = withActivity { onBackPressedDispatcher }
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+                dispatcher.dispatchOnBackProgressed(
+                    BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+                dispatcher.dispatchOnBackProgressed(
+                    BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            withActivity {
+                dispatcher.dispatchOnBackStarted(
+                    BackEventCompat(0.1F, 0.1F, 0.1F, BackEvent.EDGE_LEFT)
+                )
+                dispatcher.dispatchOnBackProgressed(
+                    BackEventCompat(0.2F, 0.2F, 0.2F, BackEvent.EDGE_LEFT)
+                )
+            }
+            withActivity { dispatcher.onBackPressed() }
+            waitForExecution()
+
+            fragment1.waitForNoTransition()
+
+            assertThat(fragment2.isAdded).isFalse()
+            assertThat(fm1.findFragmentByTag("2")).isEqualTo(null)
+
+            // Make sure the original fragment was correctly readded to the container
+            assertThat(fragment1.requireView().parent).isNotNull()
+        }
+    }
+
+    @Test
     fun replaceOperationWithTransitionsThenBackCancelled() {
         withUse(ActivityScenario.launch(FragmentTransitionTestActivity::class.java)) {
             val fm1 = withActivity { supportFragmentManager }
@@ -141,7 +336,7 @@
                                 startedEnterCountDownLatch.countDown()
                             }
 
-                            override fun onTransitionEnd(transition: Transition) {
+                            override fun onTransitionCancel(transition: Transition) {
                                 transitionEndCountDownLatch.countDown()
                             }
                         }
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/SwipeToReveal.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/SwipeToReveal.kt
index c1c7e8e..26080e1 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/SwipeToReveal.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/SwipeToReveal.kt
@@ -266,9 +266,12 @@
         if (targetValue != RevealValue.Covered) {
             resetLastState(this)
         }
-        swipeableState.animateTo(targetValue)
-        if (targetValue == RevealValue.Covered) {
-            lastActionType = RevealActionType.None
+        try {
+            swipeableState.animateTo(targetValue)
+        } finally {
+            if (targetValue == RevealValue.Covered) {
+                lastActionType = RevealActionType.None
+            }
         }
     }
 
diff --git a/wear/compose/compose-material3/api/current.txt b/wear/compose/compose-material3/api/current.txt
index ce8c266..e6fff1a 100644
--- a/wear/compose/compose-material3/api/current.txt
+++ b/wear/compose/compose-material3/api/current.txt
@@ -918,7 +918,7 @@
 
   public final class SegmentedCircularProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
-    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> completed, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> segmentValue, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
   public final class ShapeDefaults {
@@ -1172,6 +1172,31 @@
     method @androidx.compose.runtime.Composable public static void SwipeToDismissBox(kotlin.jvm.functions.Function0<kotlin.Unit> onDismissed, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.foundation.SwipeToDismissBoxState state, optional long backgroundScrimColor, optional long contentScrimColor, optional Object backgroundKey, optional Object contentKey, optional boolean userSwipeEnabled, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.layout.BoxScope,? super java.lang.Boolean,kotlin.Unit> content);
   }
 
+  public final class SwipeToRevealDefaults {
+    method public float getDoubleActionAnchorWidth();
+    method public float getLargeActionButtonHeight();
+    method public float getSingleActionAnchorWidth();
+    method public float getSmallActionButtonHeight();
+    property public final float DoubleActionAnchorWidth;
+    property public final float LargeActionButtonHeight;
+    property public final float SingleActionAnchorWidth;
+    property public final float SmallActionButtonHeight;
+    field public static final androidx.wear.compose.material3.SwipeToRevealDefaults INSTANCE;
+  }
+
+  public final class SwipeToRevealKt {
+    method @androidx.compose.runtime.Composable public static void SwipeToReveal(kotlin.jvm.functions.Function1<? super androidx.wear.compose.material3.SwipeToRevealScope,kotlin.Unit> actions, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.foundation.RevealState revealState, optional float actionButtonHeight, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static androidx.wear.compose.foundation.RevealState rememberRevealState(optional int initialValue, optional float anchorWidth, optional boolean useAnchoredActions);
+  }
+
+  public final class SwipeToRevealScope {
+    ctor public SwipeToRevealScope();
+    method public void primaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> icon, String label, optional long containerColor, optional long contentColor);
+    method public void secondaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> icon, String label, optional long containerColor, optional long contentColor);
+    method public void undoPrimaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional long containerColor, optional long contentColor);
+    method public void undoSecondaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional long containerColor, optional long contentColor);
+  }
+
   @androidx.compose.runtime.Immutable public final class SwitchButtonColors {
     ctor public SwitchButtonColors(long checkedContainerColor, long checkedContentColor, long checkedSecondaryContentColor, long checkedIconColor, long checkedThumbColor, long checkedThumbIconColor, long checkedTrackBorderColor, long checkedTrackColor, long uncheckedContainerColor, long uncheckedContentColor, long uncheckedSecondaryContentColor, long uncheckedIconColor, long uncheckedThumbColor, long uncheckedTrackColor, long uncheckedTrackBorderColor, long disabledCheckedContainerColor, long disabledCheckedContentColor, long disabledCheckedSecondaryContentColor, long disabledCheckedIconColor, long disabledCheckedThumbColor, long disabledCheckedThumbIconColor, long disabledCheckedTrackColor, long disabledCheckedTrackBorderColor, long disabledUncheckedContainerColor, long disabledUncheckedContentColor, long disabledUncheckedSecondaryContentColor, long disabledUncheckedIconColor, long disabledUncheckedThumbColor, long disabledUncheckedTrackBorderColor);
     method public long getCheckedContainerColor();
diff --git a/wear/compose/compose-material3/api/restricted_current.txt b/wear/compose/compose-material3/api/restricted_current.txt
index ce8c266..e6fff1a 100644
--- a/wear/compose/compose-material3/api/restricted_current.txt
+++ b/wear/compose/compose-material3/api/restricted_current.txt
@@ -918,7 +918,7 @@
 
   public final class SegmentedCircularProgressIndicatorKt {
     method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function0<java.lang.Float> progress, optional androidx.compose.ui.Modifier modifier, optional boolean allowProgressOverflow, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
-    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> completed, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
+    method @androidx.compose.runtime.Composable public static void SegmentedCircularProgressIndicator(@IntRange(from=1L) int segmentCount, kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.Boolean> segmentValue, optional androidx.compose.ui.Modifier modifier, optional float startAngle, optional float endAngle, optional androidx.wear.compose.material3.ProgressIndicatorColors colors, optional float strokeWidth, optional float gapSize, optional boolean enabled);
   }
 
   public final class ShapeDefaults {
@@ -1172,6 +1172,31 @@
     method @androidx.compose.runtime.Composable public static void SwipeToDismissBox(kotlin.jvm.functions.Function0<kotlin.Unit> onDismissed, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.foundation.SwipeToDismissBoxState state, optional long backgroundScrimColor, optional long contentScrimColor, optional Object backgroundKey, optional Object contentKey, optional boolean userSwipeEnabled, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.layout.BoxScope,? super java.lang.Boolean,kotlin.Unit> content);
   }
 
+  public final class SwipeToRevealDefaults {
+    method public float getDoubleActionAnchorWidth();
+    method public float getLargeActionButtonHeight();
+    method public float getSingleActionAnchorWidth();
+    method public float getSmallActionButtonHeight();
+    property public final float DoubleActionAnchorWidth;
+    property public final float LargeActionButtonHeight;
+    property public final float SingleActionAnchorWidth;
+    property public final float SmallActionButtonHeight;
+    field public static final androidx.wear.compose.material3.SwipeToRevealDefaults INSTANCE;
+  }
+
+  public final class SwipeToRevealKt {
+    method @androidx.compose.runtime.Composable public static void SwipeToReveal(kotlin.jvm.functions.Function1<? super androidx.wear.compose.material3.SwipeToRevealScope,kotlin.Unit> actions, optional androidx.compose.ui.Modifier modifier, optional androidx.wear.compose.foundation.RevealState revealState, optional float actionButtonHeight, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+    method @androidx.compose.runtime.Composable public static androidx.wear.compose.foundation.RevealState rememberRevealState(optional int initialValue, optional float anchorWidth, optional boolean useAnchoredActions);
+  }
+
+  public final class SwipeToRevealScope {
+    ctor public SwipeToRevealScope();
+    method public void primaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> icon, String label, optional long containerColor, optional long contentColor);
+    method public void secondaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> icon, String label, optional long containerColor, optional long contentColor);
+    method public void undoPrimaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional long containerColor, optional long contentColor);
+    method public void undoSecondaryAction(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, String label, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional long containerColor, optional long contentColor);
+  }
+
   @androidx.compose.runtime.Immutable public final class SwitchButtonColors {
     ctor public SwitchButtonColors(long checkedContainerColor, long checkedContentColor, long checkedSecondaryContentColor, long checkedIconColor, long checkedThumbColor, long checkedThumbIconColor, long checkedTrackBorderColor, long checkedTrackColor, long uncheckedContainerColor, long uncheckedContentColor, long uncheckedSecondaryContentColor, long uncheckedIconColor, long uncheckedThumbColor, long uncheckedTrackColor, long uncheckedTrackBorderColor, long disabledCheckedContainerColor, long disabledCheckedContentColor, long disabledCheckedSecondaryContentColor, long disabledCheckedIconColor, long disabledCheckedThumbColor, long disabledCheckedThumbIconColor, long disabledCheckedTrackColor, long disabledCheckedTrackBorderColor, long disabledUncheckedContainerColor, long disabledUncheckedContentColor, long disabledUncheckedSecondaryContentColor, long disabledUncheckedIconColor, long disabledUncheckedThumbColor, long disabledUncheckedTrackBorderColor);
     method public long getCheckedContainerColor();
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
index 88c23e1..1bf97f8 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/ProgressIndicatorDemo.kt
@@ -54,7 +54,7 @@
 import androidx.wear.compose.material3.samples.LinearProgressIndicatorSample
 import androidx.wear.compose.material3.samples.MediaButtonProgressIndicatorSample
 import androidx.wear.compose.material3.samples.OverflowProgressIndicatorSample
-import androidx.wear.compose.material3.samples.SegmentedProgressIndicatorOnOffSample
+import androidx.wear.compose.material3.samples.SegmentedProgressIndicatorBinarySample
 import androidx.wear.compose.material3.samples.SegmentedProgressIndicatorSample
 import androidx.wear.compose.material3.samples.SmallSegmentedProgressIndicatorSample
 import androidx.wear.compose.material3.samples.SmallValuesProgressIndicatorSample
@@ -93,8 +93,8 @@
             Centralize { IndeterminateProgressIndicatorSample() }
         },
         ComposableDemo("Segmented progress") { Centralize { SegmentedProgressIndicatorSample() } },
-        ComposableDemo("Progress segments on/off") {
-            Centralize { SegmentedProgressIndicatorOnOffSample() }
+        ComposableDemo("Segmented binary") {
+            Centralize { SegmentedProgressIndicatorBinarySample() }
         },
         ComposableDemo("Small segmented progress") {
             Centralize { SmallSegmentedProgressIndicatorSample() }
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SwipeToRevealDemo.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SwipeToRevealDemo.kt
new file mode 100644
index 0000000..0c777e61
--- /dev/null
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/SwipeToRevealDemo.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3.demos
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealValue
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.material3.Button
+import androidx.wear.compose.material3.Card
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.SplitSwitchButton
+import androidx.wear.compose.material3.SwipeToReveal
+import androidx.wear.compose.material3.SwipeToRevealDefaults
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.material3.rememberRevealState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+fun SwipeToRevealTwoActionsWithUndo() {
+    val context = LocalContext.current
+    val showToasts = remember { mutableStateOf(true) }
+
+    Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        SwipeToReveal(
+            // Use the double action anchor width when revealing two actions
+            revealState =
+                rememberRevealState(anchorWidth = SwipeToRevealDefaults.DoubleActionAnchorWidth),
+            actionButtonHeight = SwipeToRevealDefaults.LargeActionButtonHeight,
+            actions = {
+                primaryAction(
+                    onClick = {
+                        if (showToasts.value) {
+                            Toast.makeText(context, "Primary action executed.", Toast.LENGTH_SHORT)
+                                .show()
+                        }
+                    },
+                    icon = { Icon(Icons.Outlined.Delete, contentDescription = "Delete") },
+                    label = "Delete"
+                )
+                secondaryAction(
+                    onClick = {
+                        if (showToasts.value) {
+                            Toast.makeText(
+                                    context,
+                                    "Secondary action executed.",
+                                    Toast.LENGTH_SHORT
+                                )
+                                .show()
+                        }
+                    },
+                    icon = { Icon(Icons.Filled.Lock, contentDescription = "Lock") },
+                    label = "Lock"
+                )
+                undoPrimaryAction(
+                    onClick = {
+                        if (showToasts.value) {
+                            Toast.makeText(
+                                    context,
+                                    "Undo primary action executed.",
+                                    Toast.LENGTH_SHORT
+                                )
+                                .show()
+                        }
+                    },
+                    label = "Undo Delete"
+                )
+                undoSecondaryAction(
+                    onClick = {
+                        if (showToasts.value) {
+                            Toast.makeText(
+                                    context,
+                                    "Undo secondary action executed.",
+                                    Toast.LENGTH_SHORT
+                                )
+                                .show()
+                        }
+                    },
+                    label = "Undo Lock"
+                )
+            }
+        ) {
+            Card(modifier = Modifier.fillMaxWidth(), onClick = {}) {
+                Text("This Card has two actions", modifier = Modifier.fillMaxSize())
+            }
+        }
+        Spacer(Modifier.size(4.dp))
+        SplitSwitchButton(
+            showToasts.value,
+            onCheckedChange = { showToasts.value = it },
+            onContainerClick = { showToasts.value = !showToasts.value },
+            toggleContentDescription = "Show toasts"
+        ) {
+            Text("Show toasts")
+        }
+    }
+}
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+fun SwipeToRevealInList() {
+    val namesList = remember { mutableStateListOf("Alice", "Bob", "Charlie", "Dave", "Eve") }
+    val coroutineScope = rememberCoroutineScope()
+    ScalingLazyColumn(contentPadding = PaddingValues(0.dp)) {
+        items(namesList.size, key = { namesList[it] }) {
+            val revealState =
+                rememberRevealState(anchorWidth = SwipeToRevealDefaults.DoubleActionAnchorWidth)
+            val name = remember { namesList[it] }
+            SwipeToReveal(
+                revealState = revealState,
+                actions = {
+                    primaryAction(
+                        onClick = {
+                            coroutineScope.launch {
+                                delay(2000)
+                                // After a delay, remove the item from the list if the last action
+                                // performed by the user is still the primary action, so the user
+                                // didn't press "Undo".
+                                if (revealState.lastActionType == RevealActionType.PrimaryAction) {
+                                    namesList.remove(name)
+                                }
+                            }
+                        },
+                        icon = { Icon(Icons.Outlined.Delete, contentDescription = "Delete") },
+                        label = "Delete"
+                    )
+                    secondaryAction(
+                        onClick = {
+                            // Add a duplicate item to the list, if it doesn't exist already
+                            val nextName = "$name+"
+                            if (!namesList.contains(nextName)) {
+                                namesList.add(namesList.indexOf(name) + 1, nextName)
+                                coroutineScope.launch { revealState.animateTo(RevealValue.Covered) }
+                            }
+                        },
+                        icon = { Icon(Icons.Filled.Add, contentDescription = "Duplicate") },
+                        label = "Duplicate"
+                    )
+                    undoPrimaryAction(onClick = {}, label = "Undo Delete")
+                }
+            ) {
+                Button({}, Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { Text(name) }
+            }
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
index 5e2a401..fb753ac 100644
--- a/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
+++ b/wear/compose/compose-material3/integration-tests/src/main/java/androidx/wear/compose/material3/demos/WearMaterial3Demos.kt
@@ -38,6 +38,9 @@
 import androidx.wear.compose.material3.samples.StepperSample
 import androidx.wear.compose.material3.samples.StepperWithIntegerSample
 import androidx.wear.compose.material3.samples.StepperWithRangeSemanticsSample
+import androidx.wear.compose.material3.samples.SwipeToRevealNonAnchoredSample
+import androidx.wear.compose.material3.samples.SwipeToRevealSample
+import androidx.wear.compose.material3.samples.SwipeToRevealSingleActionCardSample
 
 val WearMaterial3Demos =
     Material3DemoCategory(
@@ -154,6 +157,23 @@
                 )
             ),
             Material3DemoCategory(
+                title = "Swipe to Reveal",
+                listOf(
+                    ComposableDemo("Two Actions") { Centralize { SwipeToRevealSample() } },
+                    ComposableDemo("Two Undo Actions") {
+                        Centralize { SwipeToRevealTwoActionsWithUndo() }
+                    },
+                    ComposableDemo("Single action with Card") {
+                        Centralize { SwipeToRevealSingleActionCardSample() }
+                    },
+                    ComposableDemo("In a list") { Centralize { SwipeToRevealInList() } },
+                    ComposableDemo("Non-anchoring") {
+                        Centralize { SwipeToRevealNonAnchoredSample() }
+                    }
+                )
+            ),
+            Material3DemoCategory(title = "Typography", TypographyDemos),
+            Material3DemoCategory(
                 "Animated Text",
                 if (Build.VERSION.SDK_INT > 31) {
                     listOf(
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
index 47a6bd2..90c6d1f 100644
--- a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/ProgressIndicatorSample.kt
@@ -169,7 +169,7 @@
 
 @Sampled
 @Composable
-fun SegmentedProgressIndicatorOnOffSample() {
+fun SegmentedProgressIndicatorBinarySample() {
     Box(
         modifier =
             Modifier.background(MaterialTheme.colorScheme.background)
@@ -178,7 +178,7 @@
     ) {
         SegmentedCircularProgressIndicator(
             segmentCount = 5,
-            completed = { it % 2 != 0 },
+            segmentValue = { it % 2 != 0 },
         )
     }
 }
@@ -189,7 +189,7 @@
     Box(modifier = Modifier.fillMaxSize()) {
         SegmentedCircularProgressIndicator(
             segmentCount = 8,
-            completed = { it % 2 != 0 },
+            segmentValue = { it % 2 != 0 },
             modifier = Modifier.align(Alignment.Center).size(80.dp)
         )
     }
diff --git a/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SwipeToRevealSample.kt b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SwipeToRevealSample.kt
new file mode 100644
index 0000000..7e5ccbb
--- /dev/null
+++ b/wear/compose/compose-material3/samples/src/main/java/androidx/wear/compose/material3/samples/SwipeToRevealSample.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.material3.Button
+import androidx.wear.compose.material3.Card
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.SwipeToReveal
+import androidx.wear.compose.material3.SwipeToRevealDefaults
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.material3.rememberRevealState
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+@Sampled
+fun SwipeToRevealSample() {
+    SwipeToReveal(
+        // Use the double action anchor width when revealing two actions
+        revealState =
+            rememberRevealState(anchorWidth = SwipeToRevealDefaults.DoubleActionAnchorWidth),
+        actions = {
+            primaryAction(
+                onClick = { /* This block is called when the primary action is executed. */ },
+                icon = { Icon(Icons.Outlined.Delete, contentDescription = "Delete") },
+                label = "Delete"
+            )
+            secondaryAction(
+                onClick = { /* This block is called when the secondary action is executed. */ },
+                icon = { Icon(Icons.Outlined.MoreVert, contentDescription = "Options") },
+                label = "Options"
+            )
+            undoPrimaryAction(
+                onClick = { /* This block is called when the undo primary action is executed. */ },
+                label = "Undo Delete"
+            )
+        }
+    ) {
+        Button(modifier = Modifier.fillMaxWidth(), onClick = {}) {
+            Text("This Button has two actions", modifier = Modifier.fillMaxSize())
+        }
+    }
+}
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+@Sampled
+fun SwipeToRevealSingleActionCardSample() {
+    SwipeToReveal(
+        actionButtonHeight = SwipeToRevealDefaults.LargeActionButtonHeight,
+        actions = {
+            primaryAction(
+                onClick = { /* This block is called when the primary action is executed. */ },
+                icon = { Icon(Icons.Outlined.Delete, contentDescription = "Delete") },
+                label = "Delete"
+            )
+        }
+    ) {
+        Card(modifier = Modifier.fillMaxWidth(), onClick = {}) {
+            Text(
+                "This Card has one action, and the revealed button is taller",
+                modifier = Modifier.fillMaxSize()
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+@Sampled
+fun SwipeToRevealNonAnchoredSample() {
+    SwipeToReveal(
+        revealState = rememberRevealState(useAnchoredActions = false),
+        actions = {
+            primaryAction(
+                onClick = { /* This block is called when the primary action is executed. */ },
+                icon = { Icon(Icons.Outlined.Delete, contentDescription = "Delete") },
+                label = "Delete"
+            )
+            undoPrimaryAction(
+                onClick = { /* This block is called when the undo primary action is executed. */ },
+                icon = { Icon(Icons.Outlined.Refresh, contentDescription = "Undo") },
+                label = "Undo"
+            )
+        }
+    ) {
+        Button(modifier = Modifier.fillMaxWidth(), onClick = {}) {
+            Text("Swipe to execute the primary action.", modifier = Modifier.fillMaxSize())
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
index 66b4f3a..2d74abc 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ProgressIndicatorScreenshotTest.kt
@@ -207,7 +207,7 @@
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
-                completed = { it % 2 == 0 },
+                segmentValue = { it % 2 == 0 },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
                 endAngle = 60f,
@@ -219,7 +219,7 @@
         verifyProgressIndicatorScreenshot(screenSize = screenSize) {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
-                completed = { it % 2 == 0 },
+                segmentValue = { it % 2 == 0 },
                 modifier = Modifier.aspectRatio(1f).testTag(TEST_TAG),
                 startAngle = 120f,
                 endAngle = 60f,
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
index 2182465..a301dbe 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SegmentedCircularProgressIndicatorTest.kt
@@ -214,7 +214,7 @@
         setContentWithTheme {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
-                completed = { it % 2 != 0 },
+                segmentValue = { it % 2 != 0 },
                 modifier = Modifier.testTag(TEST_TAG),
                 colors =
                     ProgressIndicatorDefaults.colors(
@@ -243,7 +243,7 @@
         setContentWithTheme {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
-                completed = { true },
+                segmentValue = { true },
                 modifier = Modifier.testTag(TEST_TAG),
                 colors =
                     ProgressIndicatorDefaults.colors(
@@ -267,7 +267,7 @@
         setContentWithTheme {
             SegmentedCircularProgressIndicator(
                 segmentCount = 6,
-                completed = { false },
+                segmentValue = { false },
                 modifier = Modifier.testTag(TEST_TAG),
                 colors =
                     ProgressIndicatorDefaults.colors(
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwipeToRevealScreenshotTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwipeToRevealScreenshotTest.kt
new file mode 100644
index 0000000..70df208
--- /dev/null
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/SwipeToRevealScreenshotTest.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3
+
+import android.os.Build
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealValue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestName
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+class SwipeToRevealScreenshotTest {
+    @get:Rule val rule = createComposeRule()
+
+    @get:Rule val screenshotRule = AndroidXScreenshotTestRule(SCREENSHOT_GOLDEN_PATH)
+
+    @get:Rule val testName = TestName()
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToReveal_showsPrimaryAction() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState = rememberRevealState(initialValue = RevealValue.Revealing),
+                actions = {
+                    primaryAction(
+                        {},
+                        { Icon(Icons.Outlined.Close, contentDescription = "Clear") },
+                        "Clear"
+                    )
+                }
+            ) {
+                Button({}, Modifier.fillMaxWidth()) {
+                    Text("This text should be partially visible.")
+                }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToReveal_showsPrimaryAndSecondaryActions() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState =
+                    rememberRevealState(
+                        initialValue = RevealValue.Revealing,
+                        anchorWidth = SwipeToRevealDefaults.DoubleActionAnchorWidth
+                    ),
+                actions = {
+                    primaryAction(
+                        {},
+                        { Icon(Icons.Outlined.Close, contentDescription = "Clear") },
+                        "Clear"
+                    )
+                    secondaryAction(
+                        {},
+                        { Icon(Icons.Outlined.MoreVert, contentDescription = "More") },
+                        "More"
+                    )
+                }
+            ) {
+                Button({}, Modifier.fillMaxWidth()) {
+                    Text("This text should be partially visible.")
+                }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToReveal_showsUndoPrimaryAction() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState = rememberRevealState(initialValue = RevealValue.Revealed),
+                actions = {
+                    primaryAction({}, /* Empty for testing */ {}, /* Empty for testing */ "")
+                    undoPrimaryAction({}, "Undo Primary Action")
+                }
+            ) {
+                Button({}) { Text(/* Empty for testing */ "") }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToReveal_showsUndoSecondaryAction() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState =
+                    rememberRevealState(initialValue = RevealValue.Revealed).apply {
+                        lastActionType = RevealActionType.SecondaryAction
+                    },
+                actions = {
+                    primaryAction({}, /* Empty for testing */ {}, /* Empty for testing */ "")
+                    undoPrimaryAction({}, /* Empty for testing */ "")
+                    secondaryAction({}, /* Empty for testing */ {}, /* Empty for testing */ "")
+                    undoSecondaryAction({}, "Undo Secondary Action")
+                }
+            ) {
+                Button({}) { Text(/* Empty for testing */ "") }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToReveal_showsContent() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                actions = {
+                    primaryAction({}, /* Empty for testing */ {}, /* Empty for testing */ "")
+                }
+            ) {
+                Button({}, Modifier.fillMaxWidth()) {
+                    Text("This content should be fully visible.")
+                }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToRevealCard_showsLargePrimaryAction() {
+        rule.verifyScreenshot(testName.methodName, screenshotRule) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState = rememberRevealState(initialValue = RevealValue.Revealing),
+                actionButtonHeight = SwipeToRevealDefaults.LargeActionButtonHeight,
+                actions = {
+                    primaryAction(
+                        {},
+                        { Icon(Icons.Outlined.Close, contentDescription = "Clear") },
+                        "Clear"
+                    )
+                }
+            ) {
+                Card({}, Modifier.fillMaxWidth()) {
+                    Text("This content should be partially visible.")
+                }
+            }
+        }
+    }
+
+    @OptIn(ExperimentalWearFoundationApi::class)
+    @Test
+    fun swipeToRevealCard_showsLargePrimaryAndSecondaryActions() {
+        rule.verifyScreenshot(screenshotRule = screenshotRule, methodName = testName.methodName) {
+            SwipeToReveal(
+                modifier = Modifier.testTag(TEST_TAG),
+                revealState =
+                    rememberRevealState(
+                        initialValue = RevealValue.Revealing,
+                        anchorWidth = SwipeToRevealDefaults.DoubleActionAnchorWidth
+                    ),
+                actionButtonHeight = SwipeToRevealDefaults.LargeActionButtonHeight,
+                actions = {
+                    primaryAction(
+                        {},
+                        { Icon(Icons.Outlined.Close, contentDescription = "Clear") },
+                        "Clear"
+                    )
+                    secondaryAction(
+                        {},
+                        { Icon(Icons.Outlined.MoreVert, contentDescription = "More") },
+                        "More"
+                    )
+                }
+            ) {
+                Card({}, Modifier.fillMaxWidth()) {
+                    Text("This content should be partially visible.")
+                }
+            }
+        }
+    }
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
index c212948..f919040 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SegmentedCircularProgressIndicator.kt
@@ -99,15 +99,16 @@
  *
  * Example of [SegmentedCircularProgressIndicator] where the segments are turned on/off:
  *
- * @sample androidx.wear.compose.material3.samples.SegmentedProgressIndicatorOnOffSample
+ * @sample androidx.wear.compose.material3.samples.SegmentedProgressIndicatorBinarySample
  *
  * Example of smaller size [SegmentedCircularProgressIndicator]:
  *
  * @sample androidx.wear.compose.material3.samples.SmallSegmentedProgressIndicatorSample
  * @param segmentCount Number of equal segments that the progress indicator should be divided into.
  *   Has to be a number equal or greater to 1.
- * @param completed A function that for each segment between 1..[segmentCount] returns true if this
- *   segment has been completed, and false if this segment has not been completed.
+ * @param segmentValue A function that for each segment between 1..[segmentCount] returns true if
+ *   this segment should be displayed with the indicator color to show progress, and false if the
+ *   segment should be displayed with the track color.
  * @param modifier Modifier to be applied to the SegmentedCircularProgressIndicator.
  * @param startAngle The starting position of the progress arc, measured clockwise in degrees (0
  *   to 360) from the 3 o'clock position. For example, 0 and 360 represent 3 o'clock, 90 and 180
@@ -127,7 +128,7 @@
 @Composable
 fun SegmentedCircularProgressIndicator(
     @IntRange(from = 1) segmentCount: Int,
-    completed: (segmentIndex: Int) -> Boolean,
+    segmentValue: (segmentIndex: Int) -> Boolean,
     modifier: Modifier = Modifier,
     startAngle: Float = CircularProgressIndicatorDefaults.StartAngle,
     endAngle: Float = startAngle,
@@ -137,7 +138,7 @@
     enabled: Boolean = true,
 ) =
     SegmentedCircularProgressIndicatorImpl(
-        segmentParams = SegmentParams.Completed(completed),
+        segmentParams = SegmentParams.Binary(segmentValue),
         modifier = modifier,
         segmentCount = segmentCount,
         startAngle = startAngle,
@@ -183,9 +184,9 @@
                                 (if (segmentCount > 1) gapSweep / 2 else 0f)
 
                         when (segmentParams) {
-                            is SegmentParams.Completed -> {
+                            is SegmentParams.Binary -> {
                                 val color =
-                                    if (segmentParams.completed(segment))
+                                    if (segmentParams.segmentValue(segment))
                                         colors.indicatorBrush(enabled)
                                     else colors.trackBrush(enabled)
 
@@ -240,7 +241,7 @@
 }
 
 private sealed interface SegmentParams {
-    data class Completed(val completed: (segmentIndex: Int) -> Boolean) : SegmentParams
+    data class Binary(val segmentValue: (segmentIndex: Int) -> Boolean) : SegmentParams
 
     data class Progress(val progress: () -> Float, val allowOverflow: Boolean) : SegmentParams
 }
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt
new file mode 100644
index 0000000..5027491
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/SwipeToReveal.kt
@@ -0,0 +1,535 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
+import androidx.wear.compose.foundation.RevealActionType
+import androidx.wear.compose.foundation.RevealState
+import androidx.wear.compose.foundation.RevealValue
+import androidx.wear.compose.foundation.SwipeToReveal
+import androidx.wear.compose.foundation.createAnchors
+import androidx.wear.compose.material3.ButtonDefaults.buttonColors
+import androidx.wear.compose.material3.tokens.SwipeToRevealTokens
+import androidx.wear.compose.materialcore.screenWidthDp
+import kotlin.math.abs
+import kotlinx.coroutines.launch
+
+/**
+ * [SwipeToReveal] Material composable. This adds the option to configure up to two additional
+ * actions on a Composable: a mandatory [SwipeToRevealScope.primaryAction] and an optional
+ * [SwipeToRevealScope.secondaryAction]. These actions are initially hidden and revealed only when
+ * the [content] is swiped. These additional actions can be triggered by clicking on them after they
+ * are revealed. [SwipeToRevealScope.primaryAction] will be triggered on full swipe of the
+ * [content].
+ *
+ * For actions like "Delete", consider adding [SwipeToRevealScope.undoPrimaryAction] (displayed when
+ * the [SwipeToRevealScope.primaryAction] is activated). Adding undo composables allow users to undo
+ * the action that they just performed.
+ *
+ * [SwipeToReveal] composable adds the [CustomAccessibilityAction]s using the labels from primary
+ * and secondary actions.
+ *
+ * Example of [SwipeToReveal] with primary and secondary actions
+ *
+ * @sample androidx.wear.compose.material3.samples.SwipeToRevealSample
+ *
+ * Example of [SwipeToReveal] with a Card composable, it reveals a taller button.
+ *
+ * @sample androidx.wear.compose.material3.samples.SwipeToRevealSingleActionCardSample
+ *
+ * Example of [SwipeToReveal] that doesn't reveal the actions, instead it only executes them when
+ * fully swiped or bounces back to its initial state.
+ *
+ * @sample androidx.wear.compose.material3.samples.SwipeToRevealNonAnchoredSample
+ * @param actions Actions of the [SwipeToReveal] composable, such as
+ *   [SwipeToRevealScope.primaryAction]. [actions] should always include exactly one
+ *   [SwipeToRevealScope.primaryAction]. [SwipeToRevealScope.secondaryAction],
+ *   [SwipeToRevealScope.undoPrimaryAction] and [SwipeToRevealScope.undoSecondaryAction] are
+ *   optional.
+ * @param modifier [Modifier] to be applied on the composable
+ * @param revealState [RevealState] of the [SwipeToReveal]
+ * @param actionButtonHeight Desired height of the revealed action buttons. In case the content is a
+ *   Button composable, it's suggested to use [SwipeToRevealDefaults.SmallActionButtonHeight], and
+ *   for a Card composable, it's suggested to use [SwipeToRevealDefaults.LargeActionButtonHeight].
+ * @param content The content that will be initially displayed over the other actions provided.
+ * @see [androidx.wear.compose.foundation.SwipeToReveal]
+ */
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+fun SwipeToReveal(
+    actions: SwipeToRevealScope.() -> Unit,
+    modifier: Modifier = Modifier,
+    revealState: RevealState = rememberRevealState(),
+    actionButtonHeight: Dp = SwipeToRevealDefaults.SmallActionButtonHeight,
+    content: @Composable () -> Unit,
+) {
+    val children = SwipeToRevealScope()
+    with(children, actions)
+    val primaryAction = children.primaryAction
+    require(primaryAction != null) {
+        "PrimaryAction should be provided in actions by calling the PrimaryAction method"
+    }
+
+    SwipeToReveal(
+        modifier =
+            modifier.fillMaxWidth().semantics {
+                customActions = buildList {
+                    add(
+                        CustomAccessibilityAction(primaryAction.label) {
+                            primaryAction.onClick()
+                            true
+                        }
+                    )
+                    children.secondaryAction?.let {
+                        add(
+                            CustomAccessibilityAction(it.label) {
+                                it.onClick()
+                                true
+                            }
+                        )
+                    }
+                }
+            },
+        primaryAction = {
+            ActionButton(
+                revealState,
+                primaryAction,
+                RevealActionType.PrimaryAction,
+                actionButtonHeight,
+                children.undoPrimaryAction != null,
+            )
+        },
+        secondaryAction =
+            children.secondaryAction?.let {
+                {
+                    ActionButton(
+                        revealState,
+                        it,
+                        RevealActionType.SecondaryAction,
+                        actionButtonHeight,
+                        children.undoSecondaryAction != null,
+                    )
+                }
+            },
+        undoAction =
+            when (revealState.lastActionType) {
+                RevealActionType.SecondaryAction ->
+                    children.undoSecondaryAction?.let {
+                        {
+                            ActionButton(
+                                revealState,
+                                it,
+                                RevealActionType.UndoAction,
+                                actionButtonHeight,
+                            )
+                        }
+                    }
+                else ->
+                    children.undoPrimaryAction?.let {
+                        {
+                            ActionButton(
+                                revealState,
+                                it,
+                                RevealActionType.UndoAction,
+                                actionButtonHeight,
+                            )
+                        }
+                    }
+            },
+        onFullSwipe = {
+            // Full swipe triggers the main action, but does not set the click type.
+            // Explicitly set the click type as main action when full swipe occurs.
+            revealState.lastActionType = RevealActionType.PrimaryAction
+            primaryAction.onClick()
+        },
+        state = revealState,
+        content = content,
+    )
+}
+
+/**
+ * Scope for the actions of a [SwipeToReveal] composable. Used to define the primary, secondary,
+ * undo primary and undo secondary actions.
+ */
+class SwipeToRevealScope {
+    /**
+     * Adds the primary action to a [SwipeToReveal]. This is required and exactly one primary action
+     * should be specified. In case there are multiple, only the latest one will be displayed.
+     *
+     * @param onClick Callback to be executed when the action is performed via a full swipe, or a
+     *   button click.
+     * @param icon Icon composable to be displayed for this action.
+     * @param label Label for this action. Used to create a [CustomAccessibilityAction] for the
+     *   [SwipeToReveal] component, and to display what the action is when the user fully swipes to
+     *   execute the primary action.
+     * @param containerColor Container color for this action.
+     * @param contentColor Content color for this action.
+     */
+    fun primaryAction(
+        onClick: () -> Unit,
+        icon: @Composable () -> Unit,
+        label: String,
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified
+    ) {
+        primaryAction = SwipeToRevealAction(onClick, icon, label, containerColor, contentColor)
+    }
+
+    /**
+     * Adds the secondary action to a [SwipeToReveal]. This is optional and at most one secondary
+     * action should be specified. In case there are multiple, only the latest one will be
+     * displayed.
+     *
+     * @param onClick Callback to be executed when the action is performed via a button click.
+     * @param icon Icon composable to be displayed for this action.
+     * @param label Label for this action. Used to create a [CustomAccessibilityAction] for the
+     *   [SwipeToReveal] component.
+     * @param containerColor Container color for this action.
+     * @param contentColor Content color for this action.
+     */
+    fun secondaryAction(
+        onClick: () -> Unit,
+        icon: @Composable () -> Unit,
+        label: String,
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified
+    ) {
+        secondaryAction = SwipeToRevealAction(onClick, icon, label, containerColor, contentColor)
+    }
+
+    /**
+     * Adds the undo action for the primary action to a [SwipeToReveal]. Displayed after the user
+     * performs the primary action. This is optional and at most one undo primary action should be
+     * specified. In case there are multiple, only the latest one will be displayed.
+     *
+     * @param onClick Callback to be executed when the action is performed via a button click.
+     * @param label Label for this action. Used to display what the undo action is after the user
+     *   executes the primary action.
+     * @param icon Optional Icon composable to be displayed for this action.
+     * @param containerColor Container color for this action.
+     * @param contentColor Content color for this action.
+     */
+    fun undoPrimaryAction(
+        onClick: () -> Unit,
+        label: String,
+        icon: @Composable (() -> Unit)? = null,
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified
+    ) {
+        undoPrimaryAction = SwipeToRevealAction(onClick, icon, label, containerColor, contentColor)
+    }
+
+    /**
+     * Adds the undo action for the secondary action to a [SwipeToReveal]. Displayed after the user
+     * performs the secondary action.This is optional and at most one undo secondary action should
+     * be specified. In case there are multiple, only the latest one will be displayed.
+     *
+     * @param onClick Callback to be executed when the action is performed via a button click.
+     * @param label Label for this action. Used to display what the undo action is after the user
+     *   executes the secondary action.
+     * @param icon Optional Icon composable to be displayed for this action.
+     * @param containerColor Container color for this action.
+     * @param contentColor Content color for this action.
+     */
+    fun undoSecondaryAction(
+        onClick: () -> Unit,
+        label: String,
+        icon: @Composable (() -> Unit)? = null,
+        containerColor: Color = Color.Unspecified,
+        contentColor: Color = Color.Unspecified
+    ) {
+        undoSecondaryAction =
+            SwipeToRevealAction(onClick, icon, label, containerColor, contentColor)
+    }
+
+    internal var primaryAction: SwipeToRevealAction? = null
+    internal var undoPrimaryAction: SwipeToRevealAction? = null
+    internal var secondaryAction: SwipeToRevealAction? = null
+    internal var undoSecondaryAction: SwipeToRevealAction? = null
+}
+
+/**
+ * Creates a reveal state with Material3 specs.
+ *
+ * @param initialValue The initial value of the [RevealValue] for the [SwipeToReveal] composable.
+ * @param anchorWidth Fraction of the screen revealed items should be displayed in. Ignored if
+ *   [useAnchoredActions] is set to false, as the items won't be anchored to the screen. For a
+ *   single action SwipeToReveal component, this should be
+ *   [SwipeToRevealDefaults.SingleActionAnchorWidth], and for a double action SwipeToReveal,
+ *   [SwipeToRevealDefaults.DoubleActionAnchorWidth] to be able to display both action buttons.
+ * @param useAnchoredActions Whether the actions should stay revealed, or bounce back to hidden when
+ *   the user stops swiping. This is relevant for SwipeToReveal components with a single action. If
+ *   the developer wants a swipe to clear behaviour, this should be set to false.
+ */
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+fun rememberRevealState(
+    initialValue: RevealValue = RevealValue.Covered,
+    anchorWidth: Dp = SwipeToRevealDefaults.SingleActionAnchorWidth,
+    useAnchoredActions: Boolean = true,
+): RevealState {
+    val anchorFraction = anchorWidth.value / screenWidthDp()
+    return androidx.wear.compose.foundation.rememberRevealState(
+        initialValue = initialValue,
+        animationSpec = spring(1f, Spring.StiffnessMedium),
+        anchors =
+            createAnchors(
+                revealingAnchor = if (useAnchoredActions) anchorFraction else 0f,
+            )
+    )
+}
+
+object SwipeToRevealDefaults {
+
+    /** Width that's required to display both actions in a [SwipeToReveal] composable. */
+    val DoubleActionAnchorWidth = 130.dp
+
+    /** Width that's required to display a single action in a [SwipeToReveal] composable. */
+    val SingleActionAnchorWidth = 64.dp
+
+    /** Standard height for a small revealed action, such as when the swiped item is a Button. */
+    val SmallActionButtonHeight = 52.dp
+
+    /** Standard height for a large revealed action, such as when the swiped item is a Card. */
+    val LargeActionButtonHeight = 84.dp
+
+    internal val MinimumIconSize = 20.dp
+
+    internal val IconSize = 26.dp
+
+    internal val IconAndTextPadding = 6.dp
+
+    internal val ActionButtonContentPadding = 4.dp
+
+    internal val FullScreenPaddingFraction = 0.0625f
+}
+
+@OptIn(ExperimentalWearFoundationApi::class)
+@Composable
+internal fun ActionButton(
+    revealState: RevealState,
+    action: SwipeToRevealAction,
+    revealActionType: RevealActionType,
+    buttonHeight: Dp,
+    hasUndo: Boolean = false,
+) {
+    val containerColor =
+        action.containerColor.takeOrElse {
+            when (revealActionType) {
+                RevealActionType.PrimaryAction ->
+                    MaterialTheme.colorScheme.fromToken(
+                        SwipeToRevealTokens.PrimaryActionContainerColor
+                    )
+                RevealActionType.SecondaryAction ->
+                    MaterialTheme.colorScheme.fromToken(
+                        SwipeToRevealTokens.SecondaryActionContainerColor
+                    )
+                RevealActionType.UndoAction ->
+                    MaterialTheme.colorScheme.fromToken(
+                        SwipeToRevealTokens.UndoActionContainerColor
+                    )
+                else -> Color.Unspecified
+            }
+        }
+    val contentColor =
+        action.contentColor.takeOrElse {
+            when (revealActionType) {
+                RevealActionType.PrimaryAction ->
+                    MaterialTheme.colorScheme.fromToken(
+                        SwipeToRevealTokens.PrimaryActionContentColor
+                    )
+                RevealActionType.SecondaryAction ->
+                    MaterialTheme.colorScheme.fromToken(
+                        SwipeToRevealTokens.SecondaryActionContentColor
+                    )
+                RevealActionType.UndoAction ->
+                    MaterialTheme.colorScheme.fromToken(SwipeToRevealTokens.UndoActionContentColor)
+                else -> Color.Unspecified
+            }
+        }
+    val fullScreenPaddingDp = (screenWidthDp() * SwipeToRevealDefaults.FullScreenPaddingFraction).dp
+    val startPadding =
+        when (revealActionType) {
+            RevealActionType.UndoAction -> fullScreenPaddingDp
+            else -> 0.dp
+        }
+    val endPadding =
+        when (revealActionType) {
+            RevealActionType.UndoAction -> fullScreenPaddingDp
+            else -> 0.dp
+        }
+    val coroutineScope = rememberCoroutineScope()
+    Button(
+        modifier =
+            Modifier.height(buttonHeight)
+                .padding(startPadding, 0.dp, endPadding, 0.dp)
+                .fillMaxWidth(),
+        onClick = {
+            coroutineScope.launch {
+                try {
+                    if (revealActionType == RevealActionType.UndoAction) {
+                        revealState.animateTo(RevealValue.Covered)
+                    } else {
+                        if (hasUndo || revealActionType == RevealActionType.PrimaryAction) {
+                            revealState.lastActionType = revealActionType
+                            revealState.animateTo(RevealValue.Revealed)
+                        }
+                    }
+                } finally {
+                    // Execute onClick even if the animation gets interrupted
+                    action.onClick()
+                }
+            }
+        },
+        colors = buttonColors(containerColor = containerColor, contentColor = contentColor),
+        contentPadding = PaddingValues(SwipeToRevealDefaults.ActionButtonContentPadding),
+        shape = CircleShape
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth().fillMaxHeight(),
+            horizontalArrangement = Arrangement.Center,
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            val density = LocalDensity.current
+            val primaryActionTextRevealed = remember { mutableStateOf(false) }
+            action.icon?.let { ActionIconWrapper(it) }
+            when (revealActionType) {
+                RevealActionType.PrimaryAction ->
+                    AnimatedVisibility(
+                        visible = primaryActionTextRevealed.value,
+                        enter = fadeIn() + expandHorizontally() + scaleIn(),
+                        exit = fadeOut() + shrinkHorizontally() + scaleOut(),
+                    ) {
+                        ActionText(action, contentColor)
+                    }
+                RevealActionType.UndoAction -> ActionText(action, contentColor)
+            }
+            if (revealActionType == RevealActionType.PrimaryAction) {
+                LaunchedEffect(revealState.offset) {
+                    val minimumOffsetToRevealPx =
+                        with(density) {
+                            SwipeToRevealDefaults.DoubleActionAnchorWidth.toPx().toInt()
+                        }
+                    primaryActionTextRevealed.value =
+                        abs(revealState.offset) > minimumOffsetToRevealPx &&
+                            revealState.targetValue == RevealValue.Revealed
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ActionText(action: SwipeToRevealAction, contentColor: Color) {
+    Text(
+        modifier =
+            Modifier.padding(
+                start = action.icon?.let { SwipeToRevealDefaults.IconAndTextPadding } ?: 0.dp
+            ),
+        text = action.label,
+        color = contentColor,
+        maxLines = 1
+    )
+}
+
+@Composable
+private fun ActionIconWrapper(content: @Composable () -> Unit) {
+    val iconAlpha = remember { mutableFloatStateOf(0f) }
+    Box(
+        modifier =
+            Modifier.onGloballyPositioned { coordinates ->
+                    val currentWidthDp = coordinates.size.width.dp.value
+                    iconAlpha.floatValue =
+                        ((currentWidthDp - SwipeToRevealDefaults.MinimumIconSize.value) /
+                                (SwipeToRevealDefaults.IconSize.value -
+                                    SwipeToRevealDefaults.MinimumIconSize.value))
+                            .coerceIn(0.0f, 1.0f)
+                }
+                .size(SwipeToRevealDefaults.IconSize, Dp.Unspecified)
+                .graphicsLayer { alpha = iconAlpha.floatValue }
+    ) {
+        content()
+    }
+}
+
+/** Data class to define an action to be displayed in a [SwipeToReveal] composable. */
+internal data class SwipeToRevealAction(
+    /** Callback to be executed when the action is performed via a full swipe, or a button click. */
+    val onClick: () -> Unit,
+
+    /**
+     * Icon composable to be displayed for this action. This accepts a scale parameter that should
+     * be used to increase icon icon when an action is fully revealed.
+     */
+    val icon: @Composable (() -> Unit)?,
+
+    /**
+     * Label for this action. Used to create a [CustomAccessibilityAction] for the [SwipeToReveal]
+     * component, display what the action is when the user fully swipes to execute the primary
+     * action, or when the undo action is shown.
+     */
+    val label: String,
+
+    /**
+     * Color of the container, used for the background of the action button. This can be
+     * [Color.Unspecified], and in case it is, needs to be replaced with a default.
+     */
+    val containerColor: Color,
+
+    /**
+     * Color of the content, used for the icon and text. This can be [Color.Unspecified], and in
+     * case it is, needs to be replaced with a default.
+     */
+    val contentColor: Color,
+)
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
new file mode 100644
index 0000000..a82d210
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/SwipeToRevealTokens.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.compose.material3.tokens
+
+internal object SwipeToRevealTokens {
+    val PrimaryActionContainerColor = ColorSchemeKeyTokens.Error
+    val PrimaryActionContentColor = ColorSchemeKeyTokens.OnError
+    val SecondaryActionContainerColor = ColorSchemeKeyTokens.SurfaceContainer
+    val SecondaryActionContentColor = ColorSchemeKeyTokens.OnSurface
+    val UndoActionContainerColor = ColorSchemeKeyTokens.SurfaceContainer
+    val UndoActionContentColor = ColorSchemeKeyTokens.OnSurface
+}
diff --git a/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/BaselineProfile.kt b/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/BaselineProfile.kt
index ffb459d..e491c61 100644
--- a/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/BaselineProfile.kt
+++ b/wear/compose/integration-tests/macrobenchmark/src/main/java/androidx/wear/compose/integration/macrobenchmark/BaselineProfile.kt
@@ -25,6 +25,7 @@
 import androidx.test.uiautomator.BySelector
 import androidx.test.uiautomator.Until
 import androidx.testutils.createCompilationParams
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runners.Parameterized
@@ -76,6 +77,7 @@
     private val SWITCH = "switch"
 
     @Test
+    @Ignore("b/366137664")
     fun profile() {
         baselineRule.collect(
             packageName = PACKAGE_NAME,
diff --git a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
index bd54388..5f8e674 100644
--- a/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
+++ b/wear/watchface/watchface-style/src/main/java/androidx/wear/watchface/style/UserStyleSetting.kt
@@ -72,7 +72,7 @@
 import org.xmlpull.v1.XmlPullParser
 
 /** Wrapper around either a [CharSequence] or a string resource. */
-internal sealed class DisplayText {
+internal abstract class DisplayText {
     abstract fun toCharSequence(): CharSequence
 
     override fun toString(): String = toCharSequence().toString()
@@ -82,6 +82,10 @@
         // Intentionally empty.
     }
 
+    open fun setIndex(index: Int) {
+        // Intentionally empty.
+    }
+
     class CharSequenceDisplayText(private val charSequence: CharSequence) : DisplayText() {
         override fun toCharSequence() = charSequence
 
@@ -105,7 +109,7 @@
         private var index: Int? = null
         private var indexString: String = ""
 
-        fun setIndex(index: Int) {
+        override fun setIndex(index: Int) {
             this.index = index
         }
 
@@ -181,17 +185,9 @@
         // Assign 1 based indices to display names to allow names such as Option 1, Option 2,
         // etc...
         for ((index, option) in options.withIndex()) {
-            option.displayNameInternal?.let {
-                if (it is DisplayText.ResourceDisplayTextWithIndex) {
-                    it.setIndex(index + 1)
-                }
-            }
+            option.displayNameInternal?.setIndex(index + 1)
 
-            option.screenReaderNameInternal?.let {
-                if (it is DisplayText.ResourceDisplayTextWithIndex) {
-                    it.setIndex(index + 1)
-                }
-            }
+            option.screenReaderNameInternal?.setIndex(index + 1)
         }
     }