Merge "Add internal function to create an instance of MaterialTheme that defaults to the Material 3 Expressive subsystem values. Added samples and updated kdoc for the baseline MaterialTheme function and MaterialTheme Object's member variables." into androidx-main
diff --git a/buildSrc/public/src/main/kotlin/androidx/build/ExtractJarTask.kt b/buildSrc/public/src/main/kotlin/androidx/build/ExtractJarTask.kt
deleted file mode 100644
index a452843..0000000
--- a/buildSrc/public/src/main/kotlin/androidx/build/ExtractJarTask.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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
-
-import java.io.File
-import java.util.zip.ZipFile
-import org.gradle.api.DefaultTask
-import org.gradle.api.file.ConfigurableFileCollection
-import org.gradle.api.file.DirectoryProperty
-import org.gradle.api.tasks.InputFiles
-import org.gradle.api.tasks.OutputDirectory
-import org.gradle.api.tasks.TaskAction
-import org.gradle.work.DisableCachingByDefault
-
-/**
- * A task designed to extract the contents of one or more JAR files. This task accepts a collection
- * of JAR files as input and extracts their contents into a specified output directory.
- * Each JAR file is processed individually, and its contents are placed directly into the output
- * directory, maintaining the internal structure of the JAR files.
- */
-@DisableCachingByDefault
-abstract class ExtractJarTask : DefaultTask() {
-
-    @get:InputFiles
-    abstract val jarFiles: ConfigurableFileCollection
-
-    @get:OutputDirectory
-    abstract val outputDir: DirectoryProperty
-
-    @TaskAction
-    fun extractJars() {
-        val outputDirectory = outputDir.get().asFile
-        if (!outputDirectory.exists()) {
-            outputDirectory.mkdirs()
-        }
-
-        jarFiles.forEach { jarFile ->
-            ZipFile(jarFile).use { zip ->
-                zip.entries().asSequence().forEach { entry ->
-                    val outputFile = File(outputDirectory, entry.name)
-                    if (!entry.isDirectory) {
-                        outputFile.parentFile.mkdirs()
-                        zip.getInputStream(entry).use { input ->
-                            outputFile.outputStream().use { output ->
-                                input.copyTo(output)
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
index 19d6748..fbd8cb1 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerAccessibilityTest.kt
@@ -355,6 +355,42 @@
         }
     }
 
+    @Test
+    fun scrollBySemantics_alwaysScrollsFullPage_lessThanPage() {
+        createPager()
+
+        assertThat(pagerState.currentPage).isEqualTo(0)
+        val scrollDelta = with(rule.density) { (pageSize / 2).toDp() }
+        rule.onNodeWithTag(PagerTestTag).scrollBy(
+            x = scrollDelta,
+            y = scrollDelta,
+            density = rule.density
+        )
+
+        rule.runOnIdle {
+            assertThat(pagerState.currentPage).isEqualTo(1)
+            assertThat(pagerState.currentPageOffsetFraction).isEqualTo(0.0f)
+        }
+    }
+
+    @Test
+    fun scrollBySemantics_alwaysScrollsFullPage_TwoPages() {
+        createPager()
+
+        assertThat(pagerState.currentPage).isEqualTo(0)
+        val scrollDelta = with(rule.density) { (3 * pageSize / 2).toDp() }
+        rule.onNodeWithTag(PagerTestTag).scrollBy(
+            x = scrollDelta,
+            y = scrollDelta,
+            density = rule.density
+        )
+
+        rule.runOnIdle {
+            assertThat(pagerState.currentPage).isEqualTo(2)
+            assertThat(pagerState.currentPageOffsetFraction).isEqualTo(0.0f)
+        }
+    }
+
     private fun <T> SemanticsNodeInteraction.withSemanticsNode(block: SemanticsNode.() -> T): T {
         return block.invoke(fetchSemanticsNode())
     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
index 13ec0b5..fe44b1f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutSemanticState.kt
@@ -20,6 +20,9 @@
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.lazy.layout.LazyLayoutSemanticState
 import androidx.compose.ui.semantics.CollectionInfo
+import kotlin.math.absoluteValue
+import kotlin.math.roundToInt
+import kotlin.math.sign
 
 internal fun LazyLayoutSemanticState(
     state: PagerState,
@@ -30,7 +33,15 @@
     override val maxScrollOffset: Float
         get() = state.layoutInfo.calculateNewMaxScrollOffset(state.pageCount).toFloat()
 
-    override suspend fun animateScrollBy(delta: Float): Float = state.animateScrollBy(delta)
+    override suspend fun animateScrollBy(delta: Float): Float {
+        val pagesInDelta = if (state.pageSizeWithSpacing == 0) {
+            0
+        } else {
+            Math.ceil(delta.absoluteValue / state.pageSizeWithSpacing.toDouble()).roundToInt()
+        }
+
+        return state.animateScrollBy(pagesInDelta * state.pageSizeWithSpacing * delta.sign)
+    }
 
     override suspend fun scrollToItem(index: Int) {
         state.scrollToPage(index)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
index 9036317..cfeadf4 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
@@ -20,6 +20,7 @@
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.DecayAnimationSpec
 import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.rememberSplineBasedDecay
 import androidx.compose.foundation.gestures.FlingBehavior
@@ -293,7 +294,10 @@
         state: PagerState,
         pagerSnapDistance: PagerSnapDistance = PagerSnapDistance.atMost(1),
         decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
-        snapAnimationSpec: AnimationSpec<Float> = spring(stiffness = Spring.StiffnessMediumLow),
+        snapAnimationSpec: AnimationSpec<Float> = spring(
+            stiffness = Spring.StiffnessMediumLow,
+            visibilityThreshold = Int.VisibilityThreshold.toFloat()
+        ),
         @FloatRange(from = 0.0, to = 1.0) snapPositionalThreshold: Float = 0.5f
     ): TargetedFlingBehavior {
         require(snapPositionalThreshold in 0f..1f) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
index 4538a66..da49e44 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldScreenshotTest.kt
@@ -486,7 +486,7 @@
 
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
 @Composable
-private fun SampleThreePaneScaffoldWithPaneExpansion(
+internal fun SampleThreePaneScaffoldWithPaneExpansion(
     paneExpansionState: PaneExpansionState,
     paneExpansionDragHandle: (@Composable (PaneExpansionState) -> Unit)? = null,
 ) {
@@ -509,6 +509,6 @@
 
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
 @Composable
-private fun MockDragHandle(state: PaneExpansionState) {
+internal fun MockDragHandle(state: PaneExpansionState) {
     PaneExpansionDragHandle(state, MaterialTheme.colorScheme.outline)
 }
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
index f738efb..25b7da3 100644
--- a/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffoldTest.kt
@@ -23,13 +23,19 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -164,12 +170,147 @@
         rule.onNodeWithTag("SecondaryPane").assertExists()
         rule.onNodeWithTag("TertiaryPane").assertDoesNotExist()
     }
+
+    @Test
+    fun threePaneScaffold_paneExpansionWithDragHandle_slowDraggingAndSettling() {
+        val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+        var mockDraggingPx = 0f
+        var expectedSettledOffsetPx = 0
+        lateinit var scope: CoroutineScope
+
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            scope = rememberCoroutineScope()
+            mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
+            expectedSettledOffsetPx = with(LocalDensity.current) {
+                MockPaneExpansionMiddleAnchor.toPx().toInt()
+            }
+            SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+                MockDragHandle(it)
+            }
+        }
+
+        rule.runOnIdle {
+            mockPaneExpansionState.dispatchRawDelta(mockDraggingPx)
+            scope.launch {
+                mockPaneExpansionState.settleToAnchorIfNeeded(0F)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+                expectedSettledOffsetPx
+            )
+        }
+    }
+
+    @Test
+    fun threePaneScaffold_paneExpansionWithDragHandle_fastDraggingAndSettling() {
+        val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+        var mockDraggingPx = 0f
+        lateinit var scope: CoroutineScope
+
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            scope = rememberCoroutineScope()
+            mockDraggingPx = with(LocalDensity.current) { 200.dp.toPx() }
+            SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+                MockDragHandle(it)
+            }
+        }
+
+        rule.runOnIdle {
+            mockPaneExpansionState.dispatchRawDelta(mockDraggingPx)
+            scope.launch {
+                mockPaneExpansionState.settleToAnchorIfNeeded(400F)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+                mockPaneExpansionState.maxExpansionWidth
+            )
+        }
+    }
+
+    @Test
+    fun threePaneScaffold_paneExpansionWithDragHandle_draggingAndSettlingCloseToLeftEdge() {
+        val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+        var mockDraggingDp = 0f
+        lateinit var scope: CoroutineScope
+
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            scope = rememberCoroutineScope()
+            mockDraggingDp = with(LocalDensity.current) { -360.dp.toPx() }
+            SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+                MockDragHandle(it)
+            }
+        }
+
+        rule.runOnIdle {
+            mockPaneExpansionState.dispatchRawDelta(mockDraggingDp)
+            scope.launch {
+                mockPaneExpansionState.settleToAnchorIfNeeded(-200F)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(0)
+        }
+    }
+
+    @Test
+    fun threePaneScaffold_paneExpansionWithDragHandle_draggingAndSettlingCloseToRightEdge() {
+        val mockPaneExpansionState = PaneExpansionState(anchors = MockPaneExpansionAnchors)
+        var mockDraggingDp = 0f
+        lateinit var scope: CoroutineScope
+
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            scope = rememberCoroutineScope()
+            mockDraggingDp = with(LocalDensity.current) { 640.dp.toPx() }
+            SampleThreePaneScaffoldWithPaneExpansion(mockPaneExpansionState) {
+                MockDragHandle(it)
+            }
+        }
+
+        rule.runOnIdle {
+            mockPaneExpansionState.dispatchRawDelta(mockDraggingDp)
+            scope.launch {
+                mockPaneExpansionState.settleToAnchorIfNeeded(200F)
+            }
+        }
+
+        rule.runOnIdle {
+            assertThat(mockPaneExpansionState.currentMeasuredDraggingOffset).isEqualTo(
+                mockPaneExpansionState.maxExpansionWidth
+            )
+        }
+    }
 }
 
 private val MockScaffoldDirective = PaneScaffoldDirective.Default
 
 internal const val ThreePaneScaffoldTestTag = "SampleThreePaneScaffold"
 
+private val MockPaneExpansionMiddleAnchor = 400.dp
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private val MockPaneExpansionAnchors = listOf(
+    PaneExpansionAnchor(percentage = 0),
+    PaneExpansionAnchor(startOffset = MockPaneExpansionMiddleAnchor),
+    PaneExpansionAnchor(percentage = 100),
+)
+
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
 @Composable
 private fun SampleThreePaneScaffold(scaffoldValue: ThreePaneScaffoldValue) {
diff --git a/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt
new file mode 100644
index 0000000..6377185
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/src/androidMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.android.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.material3.adaptive.layout
+
+import androidx.compose.foundation.systemGestureExclusion as androidSystemGestureExclusion
+import androidx.compose.ui.Modifier
+
+internal actual fun Modifier.systemGestureExclusion(): Modifier =
+    this.androidSystemGestureExclusion()
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
index 5cf1465..8382ba4 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.kt
@@ -57,4 +57,10 @@
 
 @ExperimentalMaterial3AdaptiveApi
 internal fun Modifier.paneExpansionDragHandle(state: PaneExpansionState): Modifier =
-    this.draggable(state, Orientation.Horizontal)
+    this.draggable(
+        state = state,
+        orientation = Orientation.Horizontal,
+        onDragStopped = { velocity -> state.settleToAnchorIfNeeded(velocity) }
+    ).systemGestureExclusion()
+
+internal expect fun Modifier.systemGestureExclusion(): Modifier
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
index 8ae6844..10b24e4 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionState.kt
@@ -16,22 +16,35 @@
 
 package androidx.compose.material3.adaptive.layout
 
+import androidx.annotation.IntRange
+import androidx.annotation.VisibleForTesting
+import androidx.collection.IntList
+import androidx.collection.MutableIntList
+import androidx.collection.emptyIntList
+import androidx.compose.animation.core.animate
 import androidx.compose.foundation.MutatePriority
 import androidx.compose.foundation.MutatorMutex
 import androidx.compose.foundation.gestures.DragScope
 import androidx.compose.foundation.gestures.DraggableState
 import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.isSpecified
+import kotlin.math.abs
 import kotlinx.coroutines.coroutineScope
 
 @ExperimentalMaterial3AdaptiveApi
 @Stable
-internal class PaneExpansionState : DraggableState {
+internal class PaneExpansionState(
+    internal val anchors: List<PaneExpansionAnchor> = emptyList()
+) : DraggableState {
     private var firstPaneWidthState by mutableIntStateOf(UnspecifiedWidth)
     private var firstPanePercentageState by mutableFloatStateOf(Float.NaN)
     private var currentDraggingOffsetState by mutableIntStateOf(UnspecifiedWidth)
@@ -56,29 +69,33 @@
     internal var currentDraggingOffset
         get() = currentDraggingOffsetState
         private set(value) {
+            val coercedValue = value.coerceIn(0, maxExpansionWidth)
             if (value == currentDraggingOffsetState) {
                 return
             }
-            currentDraggingOffsetState = value
+            currentDraggingOffsetState = coercedValue
+            currentMeasuredDraggingOffset = coercedValue
         }
 
-    // Use this field to store the dragging offset decided by measuring instead of dragging to
-    // prevent redundant re-composition.
-    internal var currentMeasuredDraggingOffset = UnspecifiedWidth
-
     internal var isDragging by mutableStateOf(false)
         private set
 
+    internal var isSettling by mutableStateOf(false)
+        private set
+
+    internal val isDraggingOrSettling get() = isDragging || isSettling
+
+    @VisibleForTesting
     internal var maxExpansionWidth = 0
-        set(value) {
-            if (field == value) {
-                return
-            }
-            field = value
-            if (firstPaneWidth != UnspecifiedWidth) {
-                firstPaneWidth = firstPaneWidth
-            }
-        }
+        private set
+
+    // Use this field to store the dragging offset decided by measuring instead of dragging to
+    // prevent redundant re-composition.
+    @VisibleForTesting
+    internal var currentMeasuredDraggingOffset = UnspecifiedWidth
+        private set
+
+    private var anchorPositions: IntList = emptyIntList()
 
     private val dragScope: DragScope = object : DragScope {
         override fun dragBy(pixels: Float): Unit = dispatchRawDelta(pixels)
@@ -95,9 +112,7 @@
         if (currentMeasuredDraggingOffset == UnspecifiedWidth) {
             return
         }
-        currentDraggingOffset =
-            (currentMeasuredDraggingOffset + delta).toInt().coerceIn(0, maxExpansionWidth)
-        currentMeasuredDraggingOffset = currentDraggingOffset
+        currentDraggingOffset = (currentMeasuredDraggingOffset + delta).toInt()
     }
 
     override suspend fun drag(
@@ -109,7 +124,121 @@
         isDragging = false
     }
 
+    internal fun onMeasured(measuredWidth: Int, density: Density) {
+        if (measuredWidth == maxExpansionWidth) {
+            return
+        }
+        maxExpansionWidth = measuredWidth
+        if (firstPaneWidth != UnspecifiedWidth) {
+            firstPaneWidth = firstPaneWidth
+        }
+        anchorPositions = anchors.toPositions(measuredWidth, density)
+    }
+
+    internal fun onExpansionOffsetMeasured(measuredOffset: Int) {
+        currentMeasuredDraggingOffset = measuredOffset
+    }
+
+    internal suspend fun settleToAnchorIfNeeded(velocity: Float) {
+        val currentAnchorPositions = anchorPositions
+        if (currentAnchorPositions.isEmpty()) {
+            return
+        }
+        dragMutex.mutate(MutatePriority.PreventUserInput) {
+            isSettling = true
+            // TODO(conradchen): Use the right animation spec here.
+            animate(
+                currentMeasuredDraggingOffset.toFloat(),
+                currentAnchorPositions.getPositionOfTheClosestAnchor(
+                    currentMeasuredDraggingOffset,
+                    velocity
+                ).toFloat(),
+                velocity,
+            ) { value, _ ->
+                currentDraggingOffset = value.toInt()
+            }
+            isSettling = false
+        }
+    }
+
+    private fun IntList.getPositionOfTheClosestAnchor(
+        currentPosition: Int,
+        velocity: Float
+    ): Int = minBy(
+        when {
+            velocity >= AnchoringVelocityThreshold -> {
+                { anchorPosition: Int ->
+                    val delta = anchorPosition - currentPosition
+                    if (delta < 0) Int.MAX_VALUE else delta
+                }
+            }
+            velocity <= -AnchoringVelocityThreshold -> {
+                { anchorPosition: Int ->
+                    val delta = currentPosition - anchorPosition
+                    if (delta < 0) Int.MAX_VALUE else delta
+                }
+            }
+            else -> {
+                { anchorPosition: Int ->
+                    abs(currentPosition - anchorPosition)
+                }
+            }
+        }
+    )
+
     companion object {
         const val UnspecifiedWidth = -1
+        private const val AnchoringVelocityThreshold = 200F
     }
 }
+
+@ExperimentalMaterial3AdaptiveApi
+@Immutable
+internal class PaneExpansionAnchor private constructor(
+    val percentage: Int,
+    val startOffset: Dp // TODO(conradchen): confirm RTL support
+) {
+    constructor(@IntRange(0, 100) percentage: Int) : this(percentage, Dp.Unspecified)
+
+    constructor(startOffset: Dp) : this(Int.MIN_VALUE, startOffset)
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+private fun List<PaneExpansionAnchor>.toPositions(
+    maxExpansionWidth: Int,
+    density: Density
+): IntList {
+    val anchors = MutableIntList(size)
+    @Suppress("ListIterator") // Not necessarily a random-accessible list
+    forEach { anchor ->
+        if (anchor.startOffset.isSpecified) {
+            val position = with(density) { anchor.startOffset.toPx() }.toInt().let {
+                if (it < 0) maxExpansionWidth + it else it
+            }
+            if (position in 0..maxExpansionWidth) {
+                anchors.add(position)
+            }
+        } else {
+            anchors.add(maxExpansionWidth * anchor.percentage / 100)
+        }
+    }
+    anchors.sort()
+    return anchors
+}
+
+private fun <T : Comparable<T>> IntList.minBy(selector: (Int) -> T): Int {
+    if (isEmpty()) {
+        throw NoSuchElementException()
+    }
+    var minElem = this[0]
+    var minValue = selector(minElem)
+    for (i in 1 until size) {
+        val elem = this[i]
+        val value = selector(elem)
+        if (minValue > value) {
+            minElem = elem
+            minValue = value
+        }
+    }
+    return minElem
+}
diff --git a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
index 6e92343..096469a 100644
--- a/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
+++ b/compose/material3/adaptive/adaptive-layout/src/commonMain/kotlin/androidx/compose/material3/adaptive/layout/ThreePaneScaffold.kt
@@ -308,7 +308,7 @@
                 constraints.maxHeight
             )
             if (!isLookingAhead) {
-                paneExpansionState.maxExpansionWidth = outerBounds.width
+                paneExpansionState.onMeasured(outerBounds.width, this@measure)
             }
 
             if (!paneExpansionState.isUnspecified() && visiblePanes.size == 2) {
@@ -318,7 +318,7 @@
                     // Respect the user dragging result if there's any
                     val halfSpacerSize = verticalSpacerSize / 2
                     if (paneExpansionState.currentDraggingOffset <= halfSpacerSize) {
-                        val bounds = if (paneExpansionState.isDragging) {
+                        val bounds = if (paneExpansionState.isDraggingOrSettling) {
                             outerBounds.copy(
                                 left = paneExpansionState.currentDraggingOffset * 2 +
                                     outerBounds.left
@@ -333,7 +333,7 @@
                         )
                     } else if (paneExpansionState.currentDraggingOffset >=
                         outerBounds.width - halfSpacerSize) {
-                        val bounds = if (paneExpansionState.isDragging) {
+                        val bounds = if (paneExpansionState.isDraggingOrSettling) {
                             outerBounds.copy(
                                 right = paneExpansionState.currentDraggingOffset * 2 -
                                     outerBounds.right
@@ -497,7 +497,7 @@
 
             if (visiblePanes.size == 2 && dragHandleMeasurables.isNotEmpty()) {
                 val handleOffsetX =
-                    if (!paneExpansionState.isDragging ||
+                    if (!paneExpansionState.isDraggingOrSettling ||
                         paneExpansionState.currentDraggingOffset ==
                         PaneExpansionState.UnspecifiedWidth) {
                         val spacerMiddleOffset = getSpacerMiddleOffsetX(
@@ -505,7 +505,7 @@
                             visiblePanes[1]
                         )
                         if (!isLookingAhead) {
-                            paneExpansionState.currentMeasuredDraggingOffset = spacerMiddleOffset
+                            paneExpansionState.onExpansionOffsetMeasured(spacerMiddleOffset)
                         }
                         spacerMiddleOffset
                     } else {
@@ -519,8 +519,7 @@
                     handleOffsetX
                 )
             } else if (!isLookingAhead) {
-                paneExpansionState.currentMeasuredDraggingOffset =
-                    PaneExpansionState.UnspecifiedWidth
+                paneExpansionState.onExpansionOffsetMeasured(PaneExpansionState.UnspecifiedWidth)
             }
 
             // Place the hidden panes to ensure a proper motion at the AnimatedVisibility,
diff --git a/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt b/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt
new file mode 100644
index 0000000..d4a754b
--- /dev/null
+++ b/compose/material3/adaptive/adaptive-layout/src/desktopMain/kotlin/androidx/compose/material3/adaptive/layout/PaneExpansionDragHandle.desktop.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.material3.adaptive.layout
+
+import androidx.compose.ui.Modifier
+
+internal actual fun Modifier.systemGestureExclusion(): Modifier = this
diff --git a/compose/ui/ui-unit/src/androidInstrumentedTest/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactoryTest.kt b/compose/ui/ui-unit/src/androidInstrumentedTest/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactoryTest.kt
index 15f7e03..bd8b9e4 100644
--- a/compose/ui/ui-unit/src/androidInstrumentedTest/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactoryTest.kt
+++ b/compose/ui/ui-unit/src/androidInstrumentedTest/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactoryTest.kt
@@ -65,6 +65,20 @@
 
     @SmallTest
     @Test
+    fun missingLookupTable106_returnsInterpolated() {
+        // Wear uses 1.06
+        val table = FontScaleConverterFactory.forScale(1.06F)!!
+        assertThat(table.convertSpToDp(1F)).isWithin(INTERPOLATED_TOLERANCE).of(1f * 1.06F)
+        assertThat(table.convertSpToDp(8F)).isWithin(INTERPOLATED_TOLERANCE).of(8f * 1.06F)
+        assertThat(table.convertSpToDp(10F)).isWithin(INTERPOLATED_TOLERANCE).of(10f * 1.06F)
+        assertThat(table.convertSpToDp(20F)).isLessThan(20f * 1.06F)
+        assertThat(table.convertSpToDp(100F)).isLessThan(100f * 1.06F)
+        assertThat(table.convertSpToDp(5F)).isWithin(INTERPOLATED_TOLERANCE).of(5f * 1.06F)
+        assertThat(table.convertSpToDp(0F)).isWithin(INTERPOLATED_TOLERANCE).of(0f)
+    }
+
+    @SmallTest
+    @Test
     fun missingLookupTable199_returnsInterpolated() {
         val table = FontScaleConverterFactory.forScale(1.9999F)!!
         assertThat(table.convertSpToDp(1F)).isWithin(INTERPOLATED_TOLERANCE).of(2f)
@@ -123,7 +137,7 @@
     fun unnecessaryFontScalesReturnsNull() {
         assertThat(FontScaleConverterFactory.forScale(0F)).isNull()
         assertThat(FontScaleConverterFactory.forScale(1F)).isNull()
-        assertThat(FontScaleConverterFactory.forScale(1.1F)).isNull()
+        assertThat(FontScaleConverterFactory.forScale(1.02F)).isNull()
         assertThat(FontScaleConverterFactory.forScale(0.85F)).isNull()
     }
 
@@ -154,7 +168,9 @@
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(-1f)).isFalse()
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(0.85f)).isFalse()
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.02f)).isFalse()
-        assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.10f)).isFalse()
+        assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.03f)).isTrue()
+        assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.06f)).isTrue()
+        assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.10f)).isTrue()
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.15f)).isTrue()
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(1.1499999f))
                 .isTrue()
@@ -163,6 +179,42 @@
         assertThat(FontScaleConverterFactory.isNonLinearFontScalingActive(3f)).isTrue()
     }
 
+    @SmallTest
+    @Test
+    fun wearFontScalesAreMonotonicAsScaleIncreases() {
+        wearFontSizes.forEach { fontSize ->
+            var lastDp = 0f
+
+            wearFontScales.forEach { fontScale ->
+                val converter = FontScaleConverterFactory.forScale(fontScale)
+                val currentDp = converter?.convertSpToDp(fontSize) ?: (fontSize * fontScale)
+
+                assertWithMessage("Font Scale $fontScale and Size $fontSize").that(currentDp)
+                    .isAtLeast(lastDp)
+
+                lastDp = currentDp
+            }
+        }
+    }
+
+    @SmallTest
+    @Test
+    fun wearFontScalesAreMonotonicAsSpIncreases() {
+        wearFontScales.forEach { fontScale ->
+            val converter = FontScaleConverterFactory.forScale(fontScale)
+            var lastDp = 0f
+
+            wearFontSizes.forEach { fontSize ->
+                val currentDp = converter?.convertSpToDp(fontSize) ?: (fontSize * fontScale)
+
+                assertWithMessage("Font Scale $fontScale and Size $fontSize").that(currentDp)
+                    .isAtLeast(lastDp)
+
+                lastDp = currentDp
+            }
+        }
+    }
+
     @LargeTest
     @Test
     fun allFeasibleScalesAndConversionsDoNotCrash() {
@@ -245,6 +297,10 @@
     companion object {
         private const val CONVERSION_TOLERANCE = 0.05f
         private const val INTERPOLATED_TOLERANCE = 0.3f
+
+        private val wearFontScales = listOf(0.94f, 1.0f, 1.06f, 1.12f, 1.18f, 1.24f)
+        // 32 is added to check for an edge case
+        private val wearFontSizes = listOf(10f, 12f, 14f, 15f, 16f, 20f, 24f, 30f, 32f, 34f)
     }
 }
 
diff --git a/compose/ui/ui-unit/src/androidMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.android.kt b/compose/ui/ui-unit/src/androidMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.android.kt
index a20b759..d0f0121 100644
--- a/compose/ui/ui-unit/src/androidMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.android.kt
+++ b/compose/ui/ui-unit/src/androidMain/kotlin/androidx/compose/ui/unit/fontscaling/FontScaleConverterFactory.android.kt
@@ -32,7 +32,9 @@
 //  These are temporary shims until core and platform are in a stable state.
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 object FontScaleConverterFactory {
-    private const val SCALE_KEY_MULTIPLIER = 100f
+    private const val ScaleKeyMultiplier = 100f
+
+    private val CommonFontSizes = floatArrayOf(8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100f)
 
     // GuardedBy("LOOKUP_TABLES_WRITE_LOCK") but only for writes!
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -45,13 +47,13 @@
      * out of date. But all writes have to be atomic, so we use this similar to a
      * CopyOnWriteArrayList.
      */
-    private val LOOKUP_TABLES_WRITE_LOCK = arrayOfNulls<Any>(0)
-    private var sMinScaleBeforeCurvesApplied = 1.05f
+    private val LookupTablesWriteLock = arrayOfNulls<Any>(0)
+    private const val MinScaleForNonLinear = 1.03f
 
     init {
         // These were generated by frameworks/base/tools/fonts/font-scaling-array-generator.js and
         // manually tweaked for optimum readability.
-        synchronized(LOOKUP_TABLES_WRITE_LOCK) {
+        synchronized(LookupTablesWriteLock) {
             putInto(
                 sLookupTables,
                 /* scaleKey= */ 1.15f,
@@ -93,8 +95,8 @@
                 )
             )
         }
-        sMinScaleBeforeCurvesApplied = getScaleFromKey(sLookupTables.keyAt(0)) - 0.02f
-        checkPrecondition(sMinScaleBeforeCurvesApplied > 1.0f) {
+        val minScaleBeforeCurvesApplied = getScaleFromKey(sLookupTables.keyAt(0)) - 0.01f
+        checkPrecondition(minScaleBeforeCurvesApplied > MinScaleForNonLinear) {
             "You should only apply non-linear scaling to font scales > 1"
         }
     }
@@ -109,7 +111,7 @@
      */
     @AnyThread
     fun isNonLinearFontScalingActive(fontScale: Float): Boolean {
-        return fontScale >= sMinScaleBeforeCurvesApplied
+        return fontScale >= MinScaleForNonLinear
     }
 
     /**
@@ -138,7 +140,7 @@
         // Didn't find an exact match: interpolate between two existing tables
         val lowerIndex = -(index + 1) - 1
         val higherIndex = lowerIndex + 1
-        return if (lowerIndex < 0 || higherIndex >= sLookupTables.size()) {
+        return if (higherIndex >= sLookupTables.size()) {
             // We have gone beyond our bounds and have nothing to interpolate between. Just give
             // them a straight linear table instead.
             // This works because when FontScaleConverter encounters a size beyond its bounds, it
@@ -150,9 +152,19 @@
             put(fontScale, converter)
             converter
         } else {
-            val startScale = getScaleFromKey(
-                sLookupTables.keyAt(lowerIndex)
-            )
+            val startTable: FontScaleConverter
+            val startScale: Float
+            if (lowerIndex < 0) {
+                // if we're in between 1x and the first table, interpolate between them.
+                // (See b/336720383)
+                startScale = 1f
+                startTable = FontScaleConverterTable(CommonFontSizes, CommonFontSizes)
+            } else {
+                startScale = getScaleFromKey(
+                    sLookupTables.keyAt(lowerIndex)
+                )
+                startTable = sLookupTables.valueAt(lowerIndex)
+            }
             val endScale = getScaleFromKey(
                 sLookupTables.keyAt(higherIndex)
             )
@@ -165,7 +177,7 @@
                     fontScale
                 )
             val converter = createInterpolatedTableBetween(
-                sLookupTables.valueAt(lowerIndex),
+                startTable,
                 sLookupTables.valueAt(higherIndex),
                 interpolationPoint
             )
@@ -181,28 +193,27 @@
         end: FontScaleConverter,
         interpolationPoint: Float
     ): FontScaleConverter {
-        val commonSpSizes = floatArrayOf(8f, 10f, 12f, 14f, 18f, 20f, 24f, 30f, 100f)
-        val dpInterpolated = FloatArray(commonSpSizes.size)
-        for (i in commonSpSizes.indices) {
-            val sp = commonSpSizes[i]
+        val dpInterpolated = FloatArray(CommonFontSizes.size)
+        for (i in CommonFontSizes.indices) {
+            val sp = CommonFontSizes[i]
             val startDp = start.convertSpToDp(sp)
             val endDp = end.convertSpToDp(sp)
             dpInterpolated[i] = MathUtils.lerp(startDp, endDp, interpolationPoint)
         }
-        return FontScaleConverterTable(commonSpSizes, dpInterpolated)
+        return FontScaleConverterTable(CommonFontSizes, dpInterpolated)
     }
 
     private fun getKey(fontScale: Float): Int {
-        return (fontScale * SCALE_KEY_MULTIPLIER).toInt()
+        return (fontScale * ScaleKeyMultiplier).toInt()
     }
 
     private fun getScaleFromKey(key: Int): Float {
-        return key.toFloat() / SCALE_KEY_MULTIPLIER
+        return key.toFloat() / ScaleKeyMultiplier
     }
 
     private fun put(scaleKey: Float, fontScaleConverter: FontScaleConverter) {
         // Dollar-store CopyOnWriteSparseArray, since this is the only write op we need.
-        synchronized(LOOKUP_TABLES_WRITE_LOCK) {
+        synchronized(LookupTablesWriteLock) {
             val newTable = sLookupTables.clone()
             putInto(newTable, scaleKey, fontScaleConverter)
             sLookupTables = newTable
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
index 926ed7a..aea9726 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
@@ -20,6 +20,7 @@
 import android.content.Context
 import android.content.pm.PackageManager
 import android.os.Build
+import android.os.ext.SdkExtensions
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.changes.DeletionChange
 import androidx.health.connect.client.changes.UpsertionChange
@@ -54,6 +55,7 @@
 import java.time.temporal.ChronoUnit
 import kotlinx.coroutines.test.runTest
 import org.junit.After
+import org.junit.Assume.assumeFalse
 import org.junit.Before
 import org.junit.Ignore
 import org.junit.Rule
@@ -61,10 +63,9 @@
 import org.junit.runner.RunWith
 
 @RunWith(AndroidJUnit4::class)
-@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
 @MediumTest
 @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
-@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, codeName = "UpsideDownCake")
 class HealthConnectClientUpsideDownImplTest {
 
     private companion object {
@@ -393,6 +394,32 @@
         }
     }
 
+    @Test
+    fun aggregateRecords_belowSdkExt10() = runTest {
+        assumeFalse(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endTime = START_TIME + 1.minutes,
+                    endZoneOffset = ZoneOffset.UTC,
+                    transFat = Mass.grams(0.5)
+                )
+            )
+        )
+
+        val aggregateResponse = healthConnectClient.aggregate(
+            AggregateRequest(
+                setOf(NutritionRecord.TRANS_FAT_TOTAL),
+                TimeRangeFilter.none()
+            )
+        )
+
+        assertThat(aggregateResponse[NutritionRecord.TRANS_FAT_TOTAL]).isEqualTo(Mass.grams(0.5))
+    }
+
     @Ignore("b/314092270")
     @Test
     fun aggregateRecordsGroupByDuration() = runTest {
@@ -544,6 +571,7 @@
         }
     }
 
+    @Ignore("b/314092270")
     @Test
     fun aggregateRecordsGroupByPeriod_monthly_noData() = runTest {
         val queryStartTime = LocalDateTime.ofInstant(START_TIME - 40.days, ZONE_ID)
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
index 75435a1..78225d6 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
@@ -26,6 +26,7 @@
 import androidx.health.connect.client.records.NutritionRecord
 import androidx.health.connect.client.records.StepsRecord
 import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.request.AggregateRequest
 import androidx.health.connect.client.time.TimeRangeFilter
 import androidx.health.connect.client.units.Mass
 import androidx.test.core.app.ApplicationProvider
@@ -36,11 +37,11 @@
 import com.google.common.truth.Truth.assertThat
 import java.time.Duration
 import java.time.LocalDate
-import java.time.LocalDateTime
 import java.time.ZoneOffset
 import kotlinx.coroutines.flow.fold
 import kotlinx.coroutines.test.runTest
 import org.junit.After
+import org.junit.Assume.assumeFalse
 import org.junit.Assume.assumeTrue
 import org.junit.Rule
 import org.junit.Test
@@ -77,41 +78,16 @@
     }
 
     @Test
-    fun aggregateNutritionTransFatTotal_noFilters() = runTest {
+    fun aggregateFallback_sdkExt10AndAbove() = runTest {
+        assumeTrue(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+
         healthConnectClient.insertRecords(
             listOf(
                 NutritionRecord(
                     startTime = START_TIME,
                     endTime = START_TIME + 1.minutes,
                     transFat = Mass.grams(0.3),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 2.minutes,
-                    endTime = START_TIME + 3.minutes,
-                    transFat = null,
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 4.minutes,
-                    endTime = START_TIME + 5.minutes,
-                    transFat = Mass.grams(0.4),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 6.minutes,
-                    endTime = START_TIME + 7.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 8.minutes,
-                    endTime = START_TIME + 9.minutes,
-                    transFat = Mass.grams(0.5),
+                    calcium = Mass.grams(0.1),
                     startZoneOffset = ZoneOffset.UTC,
                     endZoneOffset = ZoneOffset.UTC
                 )
@@ -119,344 +95,45 @@
         )
 
         val aggregationResult =
-            healthConnectClient.aggregateNutritionTransFatTotal(TimeRangeFilter.none(), emptySet())
+            healthConnectClient.aggregateFallback(AggregateRequest(
+                metrics = setOf(NutritionRecord.TRANS_FAT_TOTAL, NutritionRecord.CALCIUM_TOTAL),
+                timeRangeFilter = TimeRangeFilter.none()
+            ))
 
-        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL]).isEqualTo(Mass.grams(1.7))
-        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+        assertThat(NutritionRecord.CALCIUM_TOTAL in aggregationResult).isFalse()
+
+        assertThat(aggregationResult.dataOrigins).isEmpty()
     }
 
     @Test
-    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter() = runTest {
+    fun aggregateFallback_belowSdkExt10() = runTest {
+        assumeFalse(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+
         healthConnectClient.insertRecords(
             listOf(
                 NutritionRecord(
                     startTime = START_TIME,
                     endTime = START_TIME + 1.minutes,
                     transFat = Mass.grams(0.3),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 2.minutes,
-                    endTime = START_TIME + 3.minutes,
-                    transFat = null,
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 4.minutes,
-                    endTime = START_TIME + 5.minutes,
-                    transFat = Mass.grams(0.4),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 6.minutes,
-                    endTime = START_TIME + 7.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 8.minutes,
-                    endTime = START_TIME + 9.minutes,
-                    transFat = Mass.grams(0.5),
+                    calcium = Mass.grams(0.1),
                     startZoneOffset = ZoneOffset.UTC,
                     endZoneOffset = ZoneOffset.UTC
                 )
             )
         )
 
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.between(
-                START_TIME + 30.seconds,
-                START_TIME + 6.minutes + 45.seconds
-            ), emptySet()
-        )
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(AggregateRequest(
+                metrics = setOf(NutritionRecord.TRANS_FAT_TOTAL, NutritionRecord.CALCIUM_TOTAL),
+                timeRangeFilter = TimeRangeFilter.none()
+            ))
 
-        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-            .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
-        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
-    }
+        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL]).isEqualTo(Mass.grams(0.3))
+        assertThat(NutritionRecord.CALCIUM_TOTAL in aggregationResult).isFalse()
 
-    @Test
-    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordEndTime() =
-        runTest {
-            healthConnectClient.insertRecords(
-                listOf(
-                    NutritionRecord(
-                        startTime = START_TIME,
-                        endTime = START_TIME + 1.minutes,
-                        transFat = Mass.grams(0.3),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    ),
-                    NutritionRecord(
-                        startTime = START_TIME + 2.minutes,
-                        endTime = START_TIME + 3.minutes,
-                        transFat = Mass.grams(0.4),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    )
-                )
-            )
-
-            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-                TimeRangeFilter.between(
-                    START_TIME + 1.minutes,
-                    START_TIME + 2.minutes
-                ), emptySet()
-            )
-
-            assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
-            assertThat(aggregationResult.dataOrigins).isEmpty()
-        }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordStartTime() =
-        runTest {
-            healthConnectClient.insertRecords(
-                listOf(
-                    NutritionRecord(
-                        startTime = START_TIME,
-                        endTime = START_TIME + 1.minutes,
-                        transFat = Mass.grams(0.3),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    ),
-                    NutritionRecord(
-                        startTime = START_TIME + 2.minutes,
-                        endTime = START_TIME + 3.minutes,
-                        transFat = Mass.grams(0.4),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    )
-                )
-            )
-
-            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-                TimeRangeFilter.between(
-                    START_TIME,
-                    START_TIME + 2.minutes
-                ), emptySet()
-            )
-
-            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-                .isEqualTo(Mass.grams(0.3))
-            assertThat(aggregationResult.dataOrigins)
-                .containsExactly(DataOrigin(context.packageName))
-        }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_recordRangeLargerThanQuery() =
-        runTest {
-            healthConnectClient.insertRecords(
-                listOf(
-                    NutritionRecord(
-                        startTime = START_TIME,
-                        endTime = START_TIME + 1.minutes,
-                        transFat = Mass.grams(0.5),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    ),
-                )
-            )
-
-            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-                TimeRangeFilter.between(
-                    START_TIME + 15.seconds,
-                    START_TIME + 45.seconds
-                ), emptySet()
-            )
-
-            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-                .isEqualTo(Mass.grams(0.25))
-            assertThat(aggregationResult.dataOrigins)
-                .containsExactly(DataOrigin(context.packageName))
-        }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_localTimeRangeFilter() = runTest {
-        healthConnectClient.insertRecords(
-            listOf(
-                NutritionRecord(
-                    startTime = START_TIME,
-                    endTime = START_TIME + 1.minutes,
-                    transFat = Mass.grams(0.3),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 2.minutes,
-                    endTime = START_TIME + 3.minutes,
-                    transFat = null,
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME - 2.hours + 4.minutes,
-                    endTime = START_TIME + 5.minutes,
-                    transFat = Mass.grams(0.4),
-                    startZoneOffset = ZoneOffset.ofHours(2),
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-                NutritionRecord(
-                    startTime = START_TIME + 3.hours + 6.minutes,
-                    endTime = START_TIME + 3.hours + 7.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.ofHours(-3),
-                    endZoneOffset = ZoneOffset.ofHours(-3)
-                ),
-                NutritionRecord(
-                    startTime = START_TIME - 4.hours + 8.minutes,
-                    endTime = START_TIME - 4.hours + 9.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.ofHours(4),
-                    endZoneOffset = ZoneOffset.ofHours(4)
-                )
-            )
-        )
-
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.between(
-                LocalDateTime.ofInstant(START_TIME + 30.seconds, ZoneOffset.UTC),
-                LocalDateTime.ofInstant(START_TIME + 6.minutes + 45.seconds, ZoneOffset.UTC)
-            ), emptySet()
-        )
-
-        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-            .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
-        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
-    }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_localTimeRangeFilter_recordRangeLargerThanQuery() =
-        runTest {
-            healthConnectClient.insertRecords(
-                listOf(
-                    NutritionRecord(
-                        startTime = START_TIME,
-                        endTime = START_TIME + 1.minutes,
-                        transFat = Mass.grams(0.5),
-                        startZoneOffset = ZoneOffset.UTC,
-                        endZoneOffset = ZoneOffset.UTC
-                    ),
-                )
-            )
-
-            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-                TimeRangeFilter.between(
-                    LocalDateTime.ofInstant(
-                        START_TIME - 2.hours + 15.seconds,
-                        ZoneOffset.ofHours(2)
-                    ),
-                    LocalDateTime.ofInstant(
-                        START_TIME - 2.hours + 45.seconds,
-                        ZoneOffset.ofHours(2)
-                    )
-                ), emptySet()
-            )
-
-            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-                .isEqualTo(Mass.grams(0.25))
-            assertThat(aggregationResult.dataOrigins)
-                .containsExactly(DataOrigin(context.packageName))
-        }
-
-    // TODO(b/337195270): Test with data origins from multiple apps
-    @Test
-    fun aggregateNutritionTransFatTotal_insertedDataOriginFilter() = runTest {
-        healthConnectClient.insertRecords(
-            listOf(
-                NutritionRecord(
-                    startTime = START_TIME,
-                    endTime = START_TIME + 1.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-            )
-        )
-
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.none(),
-            setOf(DataOrigin(context.packageName))
-        )
-
-        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
-            .isEqualTo(Mass.grams(0.5))
-        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
-    }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_timeRangeFilterOutOfBounds() = runTest {
-        healthConnectClient.insertRecords(
-            listOf(
-                NutritionRecord(
-                    startTime = START_TIME,
-                    endTime = START_TIME + 1.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-            )
-        )
-
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.after(START_TIME + 2.minutes),
-            emptySet()
-        )
-
-        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
-        assertThat(aggregationResult.dataOrigins).isEmpty()
-    }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_recordStartTimeWithNegativeZoneOffset() = runTest {
-        healthConnectClient.insertRecords(
-            listOf(
-                NutritionRecord(
-                    startTime = START_TIME,
-                    endTime = START_TIME + 60.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.ofHours(-2),
-                    endZoneOffset = ZoneOffset.UTC
-                )
-            )
-        )
-
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.between(
-                LocalDateTime.ofInstant(START_TIME, ZoneOffset.UTC),
-                LocalDateTime.ofInstant(START_TIME + 60.minutes, ZoneOffset.UTC)
-            ), emptySet()
-        )
-
-        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
-        assertThat(aggregationResult.dataOrigins).isEmpty()
-    }
-
-    @Test
-    fun aggregateNutritionTransFatTotal_nonExistingDataOriginFilter() = runTest {
-        healthConnectClient.insertRecords(
-            listOf(
-                NutritionRecord(
-                    startTime = START_TIME,
-                    endTime = START_TIME + 1.minutes,
-                    transFat = Mass.grams(0.5),
-                    startZoneOffset = ZoneOffset.UTC,
-                    endZoneOffset = ZoneOffset.UTC
-                ),
-            )
-        )
-
-        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
-            TimeRangeFilter.none(),
-            setOf(DataOrigin("some random package name"))
-        )
-
-        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
-        assertThat(aggregationResult.dataOrigins).isEmpty()
+        assertThat(aggregationResult.dataOrigins)
+            .containsExactly(DataOrigin(context.packageName))
     }
 
     @Test
@@ -542,7 +219,4 @@
 
     private val Int.minutes: Duration
         get() = Duration.ofMinutes(this.toLong())
-
-    private val Int.hours: Duration
-        get() = Duration.ofHours(this.toLong())
 }
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/NutritionAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/NutritionAggregationExtensionsTest.kt
new file mode 100644
index 0000000..db17e4f
--- /dev/null
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/NutritionAggregationExtensionsTest.kt
@@ -0,0 +1,466 @@
+/*
+ * 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.impl.platform.aggregate
+
+import android.annotation.TargetApi
+import android.content.Context
+import android.os.Build
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.impl.HealthConnectClientUpsideDownImpl
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.NutritionRecord
+import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.time.TimeRangeFilter
+import androidx.health.connect.client.units.Mass
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.rule.GrantPermissionRule
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, codeName = "UpsideDownCake")
+class NutritionAggregationExtensionsTest {
+
+    private val context: Context = ApplicationProvider.getApplicationContext()
+    private val healthConnectClient: HealthConnectClient =
+        HealthConnectClientUpsideDownImpl(context)
+
+    private companion object {
+        private val START_TIME =
+            LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
+    }
+
+    @get:Rule
+    val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+        HealthPermission.getWritePermission(NutritionRecord::class),
+        HealthPermission.getReadPermission(NutritionRecord::class)
+    )
+
+    @After
+    fun tearDown() = runTest {
+        healthConnectClient.deleteRecords(NutritionRecord::class, TimeRangeFilter.none())
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_noFilters() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.3),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 2.minutes,
+                    endTime = START_TIME + 3.minutes,
+                    transFat = null,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 4.minutes,
+                    endTime = START_TIME + 5.minutes,
+                    transFat = Mass.grams(0.4),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 6.minutes,
+                    endTime = START_TIME + 7.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 8.minutes,
+                    endTime = START_TIME + 9.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateNutritionTransFatTotal(TimeRangeFilter.none(), emptySet())
+
+        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL]).isEqualTo(Mass.grams(1.7))
+        assertThat(aggregationResult.dataOrigins)
+            .containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.3),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 2.minutes,
+                    endTime = START_TIME + 3.minutes,
+                    transFat = null,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 4.minutes,
+                    endTime = START_TIME + 5.minutes,
+                    transFat = Mass.grams(0.4),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 6.minutes,
+                    endTime = START_TIME + 7.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 8.minutes,
+                    endTime = START_TIME + 9.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                )
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.between(
+                START_TIME + 30.seconds,
+                START_TIME + 6.minutes + 45.seconds
+            ), emptySet()
+        )
+
+        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+            .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
+        assertThat(aggregationResult.dataOrigins)
+            .containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordEndTime() =
+        runTest {
+            healthConnectClient.insertRecords(
+                listOf(
+                    NutritionRecord(
+                        startTime = START_TIME,
+                        endTime = START_TIME + 1.minutes,
+                        transFat = Mass.grams(0.3),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    ),
+                    NutritionRecord(
+                        startTime = START_TIME + 2.minutes,
+                        endTime = START_TIME + 3.minutes,
+                        transFat = Mass.grams(0.4),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    )
+                )
+            )
+
+            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+                TimeRangeFilter.between(
+                    START_TIME + 1.minutes,
+                    START_TIME + 2.minutes
+                ), emptySet()
+            )
+
+            assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+            assertThat(aggregationResult.dataOrigins).isEmpty()
+        }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordStartTime() =
+        runTest {
+            healthConnectClient.insertRecords(
+                listOf(
+                    NutritionRecord(
+                        startTime = START_TIME,
+                        endTime = START_TIME + 1.minutes,
+                        transFat = Mass.grams(0.3),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    ),
+                    NutritionRecord(
+                        startTime = START_TIME + 2.minutes,
+                        endTime = START_TIME + 3.minutes,
+                        transFat = Mass.grams(0.4),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    )
+                )
+            )
+
+            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+                TimeRangeFilter.between(
+                    START_TIME,
+                    START_TIME + 2.minutes
+                ), emptySet()
+            )
+
+            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+                .isEqualTo(Mass.grams(0.3))
+            assertThat(aggregationResult.dataOrigins)
+                .containsExactly(DataOrigin(context.packageName))
+        }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_recordRangeLargerThanQuery() =
+        runTest {
+            healthConnectClient.insertRecords(
+                listOf(
+                    NutritionRecord(
+                        startTime = START_TIME,
+                        endTime = START_TIME + 1.minutes,
+                        transFat = Mass.grams(0.5),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    ),
+                )
+            )
+
+            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+                TimeRangeFilter.between(
+                    START_TIME + 15.seconds,
+                    START_TIME + 45.seconds
+                ), emptySet()
+            )
+
+            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+                .isEqualTo(Mass.grams(0.25))
+            assertThat(aggregationResult.dataOrigins)
+                .containsExactly(DataOrigin(context.packageName))
+        }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_localTimeRangeFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.3),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 2.minutes,
+                    endTime = START_TIME + 3.minutes,
+                    transFat = null,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME - 2.hours + 4.minutes,
+                    endTime = START_TIME + 5.minutes,
+                    transFat = Mass.grams(0.4),
+                    startZoneOffset = ZoneOffset.ofHours(2),
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+                NutritionRecord(
+                    startTime = START_TIME + 3.hours + 6.minutes,
+                    endTime = START_TIME + 3.hours + 7.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.ofHours(-3),
+                    endZoneOffset = ZoneOffset.ofHours(-3)
+                ),
+                NutritionRecord(
+                    startTime = START_TIME - 4.hours + 8.minutes,
+                    endTime = START_TIME - 4.hours + 9.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.ofHours(4),
+                    endZoneOffset = ZoneOffset.ofHours(4)
+                )
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME + 30.seconds, ZoneOffset.UTC),
+                LocalDateTime.ofInstant(START_TIME + 6.minutes + 45.seconds, ZoneOffset.UTC)
+            ), emptySet()
+        )
+
+        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+            .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
+        assertThat(aggregationResult.dataOrigins)
+            .containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_localTimeRangeFilter_recordRangeLargerThanQuery() =
+        runTest {
+            healthConnectClient.insertRecords(
+                listOf(
+                    NutritionRecord(
+                        startTime = START_TIME,
+                        endTime = START_TIME + 1.minutes,
+                        transFat = Mass.grams(0.5),
+                        startZoneOffset = ZoneOffset.UTC,
+                        endZoneOffset = ZoneOffset.UTC
+                    ),
+                )
+            )
+
+            val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+                TimeRangeFilter.between(
+                    LocalDateTime.ofInstant(
+                        START_TIME - 2.hours + 15.seconds,
+                        ZoneOffset.ofHours(2)
+                    ),
+                    LocalDateTime.ofInstant(
+                        START_TIME - 2.hours + 45.seconds,
+                        ZoneOffset.ofHours(2)
+                    )
+                ), emptySet()
+            )
+
+            assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+                .isEqualTo(Mass.grams(0.25))
+            assertThat(aggregationResult.dataOrigins)
+                .containsExactly(DataOrigin(context.packageName))
+        }
+
+    // TODO(b/337195270): Test with data origins from multiple apps
+    @Test
+    fun aggregateNutritionTransFatTotal_insertedDataOriginFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.none(),
+            setOf(DataOrigin(context.packageName))
+        )
+
+        assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+            .isEqualTo(Mass.grams(0.5))
+        assertThat(aggregationResult.dataOrigins)
+            .containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_timeRangeFilterOutOfBounds() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.after(START_TIME + 2.minutes),
+            emptySet()
+        )
+
+        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_recordStartTimeWithNegativeZoneOffset() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 60.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.ofHours(-2),
+                    endZoneOffset = ZoneOffset.UTC
+                )
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME, ZoneOffset.UTC),
+                LocalDateTime.ofInstant(START_TIME + 60.minutes, ZoneOffset.UTC)
+            ), emptySet()
+        )
+
+        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    @Test
+    fun aggregateNutritionTransFatTotal_nonExistingDataOriginFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                NutritionRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 1.minutes,
+                    transFat = Mass.grams(0.5),
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC
+                ),
+            )
+        )
+
+        val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+            TimeRangeFilter.none(),
+            setOf(DataOrigin("some random package name"))
+        )
+
+        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    private val Int.seconds: Duration
+        get() = Duration.ofSeconds(this.toLong())
+
+    private val Int.minutes: Duration
+        get() = Duration.ofMinutes(this.toLong())
+
+    private val Int.hours: Duration
+        get() = Duration.ofHours(this.toLong())
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
index 5b6d17e..061e250 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
@@ -39,6 +39,7 @@
 import androidx.health.connect.client.changes.DeletionChange
 import androidx.health.connect.client.changes.UpsertionChange
 import androidx.health.connect.client.impl.platform.aggregate.aggregateFallback
+import androidx.health.connect.client.impl.platform.aggregate.platformMetrics
 import androidx.health.connect.client.impl.platform.aggregate.plus
 import androidx.health.connect.client.impl.platform.records.toPlatformRecord
 import androidx.health.connect.client.impl.platform.records.toPlatformRecordClass
@@ -203,6 +204,16 @@
     }
 
     override suspend fun aggregate(request: AggregateRequest): AggregationResult {
+        if (request.metrics.isEmpty()) {
+            throw IllegalArgumentException("Requested record types must not be empty.")
+        }
+
+        val fallbackResponse = aggregateFallback(request)
+
+        if (request.platformMetrics.isEmpty()) {
+            return fallbackResponse
+        }
+
         val platformResponse = wrapPlatformException {
             suspendCancellableCoroutine { continuation ->
                 healthConnectManager.aggregate(
@@ -212,8 +223,8 @@
                 )
             }
         }
-            .toSdkResponse(request.metrics)
-        val fallbackResponse = aggregateFallback(request)
+            .toSdkResponse(request.platformMetrics)
+
         return platformResponse + fallbackResponse
     }
 
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensions.kt
index e8ea7d7..5a45b8b 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensions.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensions.kt
@@ -19,7 +19,6 @@
 package androidx.health.connect.client.impl.platform.aggregate
 
 import androidx.annotation.RequiresApi
-import androidx.annotation.VisibleForTesting
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.aggregate.AggregateMetric
 import androidx.health.connect.client.aggregate.AggregationResult
@@ -45,26 +44,20 @@
 import kotlin.reflect.KClass
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.fold
 
 // Max buffer to account for overlapping records that have startTime < timeRangeFilter.startTime
 val RECORD_START_TIME_BUFFER: Duration = Duration.ofDays(1)
 
 internal suspend fun HealthConnectClient.aggregateFallback(request: AggregateRequest):
     AggregationResult {
-    return request.fallbackMetrics.fold(
-        AggregationResult(
-            longValues = mapOf(),
-            doubleValues = mapOf(),
-            dataOrigins = setOf()
-        )
-    ) { currentAggregateResult, metric ->
-        currentAggregateResult + aggregate(
-            metric,
-            request.timeRangeFilter,
-            request.dataOriginFilter
-        )
-    }
+    return request.fallbackMetrics
+        .fold(emptyAggregationResult()) { currentAggregateResult, metric ->
+            currentAggregateResult + aggregate(
+                metric,
+                request.timeRangeFilter,
+                request.dataOriginFilter
+            )
+        }
 }
 
 private suspend fun <T : Any> HealthConnectClient.aggregate(
@@ -97,46 +90,7 @@
     }
 }
 
-@VisibleForTesting
-internal suspend fun HealthConnectClient.aggregateNutritionTransFatTotal(
-    timeRangeFilter: TimeRangeFilter,
-    dataOriginFilter: Set<DataOrigin>
-): AggregationResult {
-    val readRecordsFlow = readRecordsFlow(
-        NutritionRecord::class,
-        timeRangeFilter.withBufferedStart(),
-        dataOriginFilter
-    )
-
-    val aggregatedData = readRecordsFlow
-        .fold(AggregatedData(0.0)) { currentAggregatedData, records ->
-            val filteredRecords = records.filter {
-                it.overlaps(timeRangeFilter) && it.transFat != null &&
-                    sliceFactor(it, timeRangeFilter) > 0
-            }
-
-            filteredRecords.forEach {
-                currentAggregatedData.value +=
-                    it.transFat!!.inGrams * sliceFactor(it, timeRangeFilter)
-            }
-
-            filteredRecords.mapTo(currentAggregatedData.dataOrigins) { it.metadata.dataOrigin }
-            currentAggregatedData
-        }
-
-    if (aggregatedData.dataOrigins.isEmpty()) {
-        return emptyAggregationResult()
-    }
-
-    return AggregationResult(
-        longValues = mapOf(),
-        doubleValues = mapOf(NutritionRecord.TRANS_FAT_TOTAL.metricKey to aggregatedData.value),
-        dataOrigins = aggregatedData.dataOrigins
-    )
-}
-
 /** Reads all existing records that satisfy [timeRangeFilter] and [dataOriginFilter]. */
-@VisibleForTesting
 suspend fun <T : Record> HealthConnectClient.readRecordsFlow(
     recordType: KClass<T>,
     timeRangeFilter: TimeRangeFilter,
@@ -159,7 +113,7 @@
     }
 }
 
-private fun IntervalRecord.overlaps(timeRangeFilter: TimeRangeFilter): Boolean {
+internal fun IntervalRecord.overlaps(timeRangeFilter: TimeRangeFilter): Boolean {
     val startTimeOverlaps: Boolean
     val endTimeOverlaps: Boolean
     if (timeRangeFilter.useLocalTime()) {
@@ -180,7 +134,7 @@
     return startTimeOverlaps && endTimeOverlaps
 }
 
-private fun TimeRangeFilter.withBufferedStart(): TimeRangeFilter {
+internal fun TimeRangeFilter.withBufferedStart(): TimeRangeFilter {
     return TimeRangeFilter(
         startTime = startTime?.minus(RECORD_START_TIME_BUFFER),
         endTime = endTime,
@@ -189,7 +143,7 @@
     )
 }
 
-private fun sliceFactor(record: NutritionRecord, timeRangeFilter: TimeRangeFilter): Double {
+internal fun sliceFactor(record: NutritionRecord, timeRangeFilter: TimeRangeFilter): Double {
     val startTime: Instant
     val endTime: Instant
 
@@ -208,10 +162,10 @@
     return max(0.0, (endTime - startTime) / record.duration)
 }
 
-private fun emptyAggregationResult() =
+internal fun emptyAggregationResult() =
     AggregationResult(longValues = mapOf(), doubleValues = mapOf(), dataOrigins = setOf())
 
-private data class AggregatedData<T>(
+internal data class AggregatedData<T>(
     var value: T,
     var dataOrigins: MutableSet<DataOrigin> = mutableSetOf()
 )
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/NutritonAggregationExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/NutritonAggregationExtensions.kt
new file mode 100644
index 0000000..d7d61f6
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/NutritonAggregationExtensions.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+@file:RequiresApi(api = 34)
+
+package androidx.health.connect.client.impl.platform.aggregate
+
+import androidx.annotation.RequiresApi
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.aggregate.AggregationResult
+import androidx.health.connect.client.records.NutritionRecord
+import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.time.TimeRangeFilter
+import kotlinx.coroutines.flow.fold
+
+internal suspend fun HealthConnectClient.aggregateNutritionTransFatTotal(
+    timeRangeFilter: TimeRangeFilter,
+    dataOriginFilter: Set<DataOrigin>
+): AggregationResult {
+    val readRecordsFlow = readRecordsFlow(
+        NutritionRecord::class,
+        timeRangeFilter.withBufferedStart(),
+        dataOriginFilter
+    )
+
+    val aggregatedData = readRecordsFlow
+        .fold(AggregatedData(0.0)) { currentAggregatedData, records ->
+            val filteredRecords = records.filter {
+                it.overlaps(timeRangeFilter) && it.transFat != null &&
+                    sliceFactor(it, timeRangeFilter) > 0
+            }
+
+            filteredRecords.forEach {
+                currentAggregatedData.value +=
+                    it.transFat!!.inGrams * sliceFactor(it, timeRangeFilter)
+            }
+
+            filteredRecords.mapTo(currentAggregatedData.dataOrigins) { it.metadata.dataOrigin }
+            currentAggregatedData
+        }
+
+    if (aggregatedData.dataOrigins.isEmpty()) {
+        return emptyAggregationResult()
+    }
+
+    return AggregationResult(
+        longValues = mapOf(),
+        doubleValues = mapOf(NutritionRecord.TRANS_FAT_TOTAL.metricKey to aggregatedData.value),
+        dataOrigins = aggregatedData.dataOrigins
+    )
+}