Add remaining series types aggregations.

This should complete fallback implement for non-bucket aggregations.

Bug: 326414908
Test: added

Change-Id: I44ddf4003089695e42271d0ee14542ce03adb724
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 48d53d4..05fe60c 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,12 +26,17 @@
 import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
 import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
 import androidx.health.connect.client.records.BloodPressureRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
 import androidx.health.connect.client.records.NutritionRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsCadenceRecord
 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.Velocity
 import androidx.health.connect.client.units.grams
+import androidx.health.connect.client.units.metersPerSecond
 import androidx.health.connect.client.units.millimetersOfMercury
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -99,6 +104,23 @@
                     diastolic = 70.millimetersOfMercury,
                     systolic = 110.millimetersOfMercury
                 ),
+                CyclingPedalingCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 30.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                revolutionsPerMinute = 80.0
+                            ),
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 15.minutes,
+                                revolutionsPerMinute = 90.0
+                            )
+                        )
+                ),
                 NutritionRecord(
                     startTime = START_TIME,
                     endTime = START_TIME + 1.minutes,
@@ -106,35 +128,50 @@
                     calcium = 0.1.grams,
                     startZoneOffset = ZoneOffset.UTC,
                     endZoneOffset = ZoneOffset.UTC
+                ),
+                SpeedRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            SpeedRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                speed = Velocity.metersPerSecond(2.8)
+                            ),
+                            SpeedRecord.Sample(
+                                time = START_TIME + 10.minutes,
+                                speed = Velocity.metersPerSecond(2.7)
+                            )
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0)
+                        )
                 )
             )
         )
 
+        // Adding calcium total (which has always been supported) to make sure it's filtered out of
+        // the calculation.
         val aggregationResult =
             healthConnectClient.aggregateFallback(
                 AggregateRequest(
-                    metrics =
-                        setOf(
-                            BloodPressureRecord.DIASTOLIC_AVG,
-                            BloodPressureRecord.DIASTOLIC_MAX,
-                            BloodPressureRecord.DIASTOLIC_MIN,
-                            BloodPressureRecord.SYSTOLIC_AVG,
-                            BloodPressureRecord.SYSTOLIC_MAX,
-                            BloodPressureRecord.SYSTOLIC_MIN,
-                            NutritionRecord.TRANS_FAT_TOTAL,
-                            NutritionRecord.CALCIUM_TOTAL
-                        ),
+                    metrics = AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10 + NutritionRecord.CALCIUM_TOTAL,
                     timeRangeFilter = TimeRangeFilter.none()
                 )
             )
 
-        assertThat(BloodPressureRecord.DIASTOLIC_AVG in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.DIASTOLIC_MAX in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.DIASTOLIC_MIN in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_AVG in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_MAX in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_MIN in aggregationResult).isFalse()
-        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+        for (metric in AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10) {
+            assertThat(metric in aggregationResult).isFalse()
+        }
         assertThat(NutritionRecord.CALCIUM_TOTAL in aggregationResult).isFalse()
         assertThat(aggregationResult.dataOrigins).isEmpty()
     }
@@ -151,6 +188,23 @@
                     diastolic = 70.millimetersOfMercury,
                     systolic = 110.millimetersOfMercury
                 ),
+                CyclingPedalingCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 30.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                revolutionsPerMinute = 80.0
+                            ),
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 15.minutes,
+                                revolutionsPerMinute = 90.0
+                            )
+                        )
+                ),
                 NutritionRecord(
                     startTime = START_TIME,
                     endTime = START_TIME + 1.minutes,
@@ -158,24 +212,43 @@
                     calcium = 0.1.grams,
                     startZoneOffset = ZoneOffset.UTC,
                     endZoneOffset = ZoneOffset.UTC
+                ),
+                SpeedRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            SpeedRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                speed = Velocity.metersPerSecond(2.8)
+                            ),
+                            SpeedRecord.Sample(
+                                time = START_TIME + 10.minutes,
+                                speed = Velocity.metersPerSecond(2.7)
+                            )
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0)
+                        )
                 )
             )
         )
 
+        // Adding calcium total (which has always been supported) to make sure it's filtered out of
+        // the calculation.
         val aggregationResult =
             healthConnectClient.aggregateFallback(
                 AggregateRequest(
-                    metrics =
-                        setOf(
-                            NutritionRecord.TRANS_FAT_TOTAL,
-                            NutritionRecord.CALCIUM_TOTAL,
-                            BloodPressureRecord.DIASTOLIC_AVG,
-                            BloodPressureRecord.DIASTOLIC_MAX,
-                            BloodPressureRecord.DIASTOLIC_MIN,
-                            BloodPressureRecord.SYSTOLIC_AVG,
-                            BloodPressureRecord.SYSTOLIC_MAX,
-                            BloodPressureRecord.SYSTOLIC_MIN,
-                        ),
+                    metrics = AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10 + NutritionRecord.CALCIUM_TOTAL,
                     timeRangeFilter = TimeRangeFilter.none()
                 )
             )
@@ -187,9 +260,19 @@
             aggregationResult[BloodPressureRecord.SYSTOLIC_AVG] to 110.millimetersOfMercury,
             aggregationResult[BloodPressureRecord.SYSTOLIC_MAX] to 110.millimetersOfMercury,
             aggregationResult[BloodPressureRecord.SYSTOLIC_MIN] to 110.millimetersOfMercury,
+            aggregationResult[CyclingPedalingCadenceRecord.RPM_AVG] to 85.0,
+            aggregationResult[CyclingPedalingCadenceRecord.RPM_MAX] to 90.0,
+            aggregationResult[CyclingPedalingCadenceRecord.RPM_MIN] to 80.0,
             aggregationResult[NutritionRecord.TRANS_FAT_TOTAL] to 0.3.grams,
-            (NutritionRecord.CALCIUM_TOTAL in aggregationResult) to false,
+            aggregationResult[SpeedRecord.SPEED_AVG] to 2.75.metersPerSecond,
+            aggregationResult[SpeedRecord.SPEED_MAX] to 2.8.metersPerSecond,
+            aggregationResult[SpeedRecord.SPEED_MIN] to 2.7.metersPerSecond,
+            aggregationResult[StepsCadenceRecord.RATE_AVG] to 170.0,
+            aggregationResult[StepsCadenceRecord.RATE_MAX] to 170.0,
+            aggregationResult[StepsCadenceRecord.RATE_MIN] to 170.0,
         )
+
+        assertThat(NutritionRecord.CALCIUM_TOTAL in aggregationResult).isFalse()
         assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
     }
 
@@ -200,29 +283,15 @@
         val aggregationResult =
             healthConnectClient.aggregateFallback(
                 AggregateRequest(
-                    metrics =
-                        setOf(
-                            BloodPressureRecord.DIASTOLIC_AVG,
-                            BloodPressureRecord.DIASTOLIC_MAX,
-                            BloodPressureRecord.DIASTOLIC_MIN,
-                            BloodPressureRecord.SYSTOLIC_AVG,
-                            BloodPressureRecord.SYSTOLIC_MAX,
-                            BloodPressureRecord.SYSTOLIC_MIN,
-                            NutritionRecord.TRANS_FAT_TOTAL,
-                            NutritionRecord.CALCIUM_TOTAL
-                        ),
+                    metrics = AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10,
                     timeRangeFilter = TimeRangeFilter.none()
                 )
             )
 
-        assertThat(BloodPressureRecord.DIASTOLIC_AVG in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.DIASTOLIC_MAX in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.DIASTOLIC_MIN in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_AVG in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_MAX in aggregationResult).isFalse()
-        assertThat(BloodPressureRecord.SYSTOLIC_MIN in aggregationResult).isFalse()
-        assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
-        assertThat(NutritionRecord.CALCIUM_TOTAL in aggregationResult).isFalse()
+        for (metric in AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10) {
+            assertThat(metric in aggregationResult).isFalse()
+        }
+
         assertThat(aggregationResult.dataOrigins).isEmpty()
     }
 
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensionsTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensionsTest.kt
new file mode 100644
index 0000000..5e42b96
--- /dev/null
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensionsTest.kt
@@ -0,0 +1,717 @@
+/*
+ * 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 android.os.ext.SdkExtensions
+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.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsCadenceRecord
+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.metersPerSecond
+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.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertThrows
+import org.junit.Assume.assumeFalse
+import org.junit.Before
+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 SeriesRecordAggregationExtensionsTest {
+
+    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)
+        private val SERIES_AGGREGATION_FALLBACK_RECORD_TYPES =
+            setOf(
+                CyclingPedalingCadenceRecord::class,
+                SpeedRecord::class,
+                StepsCadenceRecord::class
+            )
+    }
+
+    @get:Rule
+    val grantPermissionRule: GrantPermissionRule =
+        GrantPermissionRule.grant(
+            *(SERIES_AGGREGATION_FALLBACK_RECORD_TYPES.flatMap {
+                    listOf(
+                        HealthPermission.getWritePermission(it),
+                        HealthPermission.getReadPermission(it)
+                    )
+                }
+                .toTypedArray())
+        )
+
+    @Before
+    fun setUp() = runTest {
+        // SDK ext 10 and above don't process any fallback metrics
+        assumeFalse(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+    }
+
+    @After
+    fun tearDown() = runTest {
+        for (recordType in SERIES_AGGREGATION_FALLBACK_RECORD_TYPES) {
+            healthConnectClient.deleteRecords(recordType, TimeRangeFilter.none())
+        }
+    }
+
+    @Test
+    fun aggregateCyclingPedalingCadence() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                CyclingPedalingCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 30.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                revolutionsPerMinute = 80.0
+                            ),
+                            CyclingPedalingCadenceRecord.Sample(
+                                time = START_TIME + 15.minutes,
+                                revolutionsPerMinute = 90.0
+                            )
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            CyclingPedalingCadenceRecord.RPM_AVG,
+                            CyclingPedalingCadenceRecord.RPM_MAX,
+                            CyclingPedalingCadenceRecord.RPM_MIN
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(aggregationResult[CyclingPedalingCadenceRecord.RPM_AVG]).isEqualTo(85.0)
+        assertThat(aggregationResult[CyclingPedalingCadenceRecord.RPM_MAX]).isEqualTo(90.0)
+        assertThat(aggregationResult[CyclingPedalingCadenceRecord.RPM_MIN]).isEqualTo(80.0)
+    }
+
+    @Test
+    fun aggregateCyclingSpeed() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                SpeedRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            SpeedRecord.Sample(
+                                time = START_TIME + 5.minutes,
+                                speed = 2.8.metersPerSecond
+                            ),
+                            SpeedRecord.Sample(
+                                time = START_TIME + 10.minutes,
+                                speed = 2.7.metersPerSecond
+                            )
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(SpeedRecord.SPEED_AVG, SpeedRecord.SPEED_MAX, SpeedRecord.SPEED_MIN),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(aggregationResult[SpeedRecord.SPEED_AVG]).isEqualTo(2.75.metersPerSecond)
+        assertThat(aggregationResult[SpeedRecord.SPEED_MAX]).isEqualTo(2.8.metersPerSecond)
+        assertThat(aggregationResult[SpeedRecord.SPEED_MIN]).isEqualTo(2.7.metersPerSecond)
+    }
+
+    @Test
+    fun aggregateStepsCadence() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_AVG]).isEqualTo(175.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MAX]).isEqualTo(180.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MIN]).isEqualTo(170.0)
+    }
+
+    @Test
+    fun aggregateSeriesRecord_noData() = runTest {
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(StepsCadenceRecord.RATE_AVG in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MAX in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MIN in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_AVG]).isEqualTo(177.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MAX]).isEqualTo(181.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MIN]).isEqualTo(170.0)
+        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords_oneMetric() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_MAX,
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none()
+                )
+            )
+
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MAX]).isEqualTo(181.0)
+        assertThat(StepsCadenceRecord.RATE_AVG in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MIN in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords_instantTimeRangeFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 18.minutes, rate = 182.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter =
+                        TimeRangeFilter.between(START_TIME + 7.minutes, START_TIME + 18.minutes)
+                )
+            )
+
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_AVG]).isEqualTo(180.5)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MAX]).isEqualTo(181.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MIN]).isEqualTo(180.0)
+        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords_instantTimeRangeFilterOutOfBounds() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 18.minutes, rate = 182.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter =
+                        TimeRangeFilter.after(
+                            START_TIME + 19.minutes,
+                        )
+                )
+            )
+
+        assertThat(StepsCadenceRecord.RATE_AVG in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MAX in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MIN in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords_localTimeRangeFilter() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 18.minutes, rate = 182.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter =
+                        TimeRangeFilter.between(
+                            LocalDateTime.ofInstant(
+                                START_TIME + 7.minutes + 2.hours,
+                                ZoneOffset.ofHours(-2)
+                            ),
+                            LocalDateTime.ofInstant(
+                                START_TIME + 18.minutes + 2.hours,
+                                ZoneOffset.ofHours(-2)
+                            )
+                        )
+                )
+            )
+
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_AVG]).isEqualTo(180.5)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MAX]).isEqualTo(181.0)
+        assertThat(aggregationResult[StepsCadenceRecord.RATE_MIN]).isEqualTo(180.0)
+        assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+    }
+
+    @Test
+    fun aggregateSeriesRecord_multipleRecords_localTimeRangeFilterOutOfBounds() = runTest {
+        healthConnectClient.insertRecords(
+            listOf(
+                StepsCadenceRecord(
+                    startTime = START_TIME,
+                    endTime = START_TIME + 10.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 3.minutes, rate = 170.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 7.minutes, rate = 180.0)
+                        )
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 11.minutes,
+                    endTime = START_TIME + 15.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples = listOf()
+                ),
+                StepsCadenceRecord(
+                    startTime = START_TIME + 16.minutes,
+                    endTime = START_TIME + 20.minutes,
+                    startZoneOffset = ZoneOffset.UTC,
+                    endZoneOffset = ZoneOffset.UTC,
+                    samples =
+                        listOf(
+                            StepsCadenceRecord.Sample(time = START_TIME + 17.minutes, rate = 181.0),
+                            StepsCadenceRecord.Sample(time = START_TIME + 18.minutes, rate = 182.0)
+                        )
+                )
+            )
+        )
+
+        val aggregationResult =
+            healthConnectClient.aggregateFallback(
+                AggregateRequest(
+                    metrics =
+                        setOf(
+                            StepsCadenceRecord.RATE_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN,
+                        ),
+                    timeRangeFilter =
+                        TimeRangeFilter.before(
+                            LocalDateTime.ofInstant(
+                                START_TIME + 2.minutes + 2.hours,
+                                ZoneOffset.ofHours(-2)
+                            )
+                        )
+                )
+            )
+
+        assertThat(StepsCadenceRecord.RATE_AVG in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MAX in aggregationResult).isFalse()
+        assertThat(StepsCadenceRecord.RATE_MIN in aggregationResult).isFalse()
+        assertThat(aggregationResult.dataOrigins).isEmpty()
+    }
+
+    @Test
+    fun aggregateSeriesRecord_invalidMetrics_throws() = runTest {
+        assertThrows(IllegalStateException::class.java) {
+            runBlocking {
+                healthConnectClient.aggregateSeriesRecord(
+                    recordType = StepsCadenceRecord::class,
+                    aggregateMetrics =
+                        setOf(
+                            SpeedRecord.SPEED_AVG,
+                            StepsCadenceRecord.RATE_MAX,
+                            StepsCadenceRecord.RATE_MIN
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none(),
+                    dataOriginFilter = emptySet()
+                ) {
+                    samples.map { SampleInfo(time = it.time, value = it.rate) }
+                }
+            }
+        }
+    }
+
+    @Test
+    fun aggregateSeriesRecord_invalidSeriesRecord_throws() = runTest {
+        assertThrows(IllegalArgumentException::class.java) {
+            runBlocking {
+                healthConnectClient.aggregateSeriesRecord(
+                    recordType = HeartRateRecord::class,
+                    aggregateMetrics =
+                        setOf(
+                            HeartRateRecord.BPM_AVG,
+                            HeartRateRecord.BPM_MAX,
+                            HeartRateRecord.BPM_MIN
+                        ),
+                    timeRangeFilter = TimeRangeFilter.none(),
+                    dataOriginFilter = emptySet()
+                ) {
+                    samples.map { SampleInfo(time = it.time, value = it.beatsPerMinute.toDouble()) }
+                }
+            }
+        }
+    }
+
+    @Test
+    fun sampleInfoIsWithin_noneTimeRangeFilter_returnsTrue() {
+        val sampleInfo = SampleInfo(time = START_TIME, value = 0.0)
+        val timeRangeFilter = TimeRangeFilter.none()
+
+        assertThat(sampleInfo.isWithin(timeRangeFilter = timeRangeFilter, zoneOffset = null))
+            .isTrue()
+    }
+
+    @Test
+    fun sampleInfoIsWithin_instantTimeRangeFilter_between() {
+        val sampleInfo = SampleInfo(time = START_TIME, value = 0.0)
+        val zoneOffset = ZoneOffset.ofHours(2)
+
+        var timeRangeFilter =
+            TimeRangeFilter.between(START_TIME - 2.minutes, START_TIME + 2.minutes)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+
+        timeRangeFilter = TimeRangeFilter.between(START_TIME - 2.minutes, START_TIME)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+
+        timeRangeFilter = TimeRangeFilter.between(START_TIME, START_TIME + 2.minutes)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+
+        timeRangeFilter = TimeRangeFilter.between(START_TIME + 1.minutes, START_TIME + 2.minutes)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+    }
+
+    @Test
+    fun sampleInfoIsWithin_instantTimeRangeFilter_openEnded() {
+        val sampleInfo = SampleInfo(time = START_TIME, value = 0.0)
+        val zoneOffset = ZoneOffset.ofHours(2)
+
+        var timeRangeFilter = TimeRangeFilter.after(START_TIME)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+
+        timeRangeFilter = TimeRangeFilter.after(START_TIME + 1.minutes)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+
+        timeRangeFilter = TimeRangeFilter.before(START_TIME)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+
+        timeRangeFilter = TimeRangeFilter.before(START_TIME + 1.minutes)
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+    }
+
+    @Test
+    fun sampleInfoIsWithin_localTimeRangeFilter_between() {
+        val sampleInfo = SampleInfo(time = START_TIME, value = 0.0)
+        val zoneOffset = ZoneOffset.ofHours(2)
+
+        var timeRangeFilter =
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME - 2.minutes, ZoneOffset.UTC),
+                LocalDateTime.ofInstant(START_TIME + 2.minutes, ZoneOffset.UTC)
+            )
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+
+        timeRangeFilter =
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME - 2.minutes, zoneOffset),
+                LocalDateTime.ofInstant(START_TIME + 2.minutes, zoneOffset)
+            )
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+
+        timeRangeFilter =
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME - 2.minutes, zoneOffset),
+                LocalDateTime.ofInstant(START_TIME, zoneOffset)
+            )
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+
+        timeRangeFilter =
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME, zoneOffset),
+                LocalDateTime.ofInstant(START_TIME + 2.minutes, zoneOffset)
+            )
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isTrue()
+
+        timeRangeFilter =
+            TimeRangeFilter.between(
+                LocalDateTime.ofInstant(START_TIME + 1.minutes, zoneOffset),
+                LocalDateTime.ofInstant(START_TIME + 2.minutes, zoneOffset)
+            )
+        assertThat(sampleInfo.isWithin(timeRangeFilter, zoneOffset)).isFalse()
+    }
+
+    private val Int.hours: Duration
+        get() = Duration.ofHours(this.toLong())
+
+    private val Int.minutes: Duration
+        get() = Duration.ofMinutes(this.toLong())
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationExtensions.kt
index f323864..787968d 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationExtensions.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationExtensions.kt
@@ -35,7 +35,7 @@
         if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
             return metrics
         }
-        return metrics.filterNot { it in SDK_EXT_10_AGGREGATE_METRICS }.toSet()
+        return metrics.filterNot { it in AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10 }.toSet()
     }
 
 internal val AggregateRequest.fallbackMetrics: Set<AggregateMetric<*>>
@@ -43,7 +43,7 @@
         if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
             return emptySet()
         }
-        return metrics.filter { it in SDK_EXT_10_AGGREGATE_METRICS }.toSet()
+        return metrics.filter { it in AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10 }.toSet()
     }
 
 internal operator fun AggregationResult.plus(other: AggregationResult): AggregationResult {
@@ -54,7 +54,7 @@
     )
 }
 
-internal val SDK_EXT_10_AGGREGATE_METRICS: Set<AggregateMetric<*>> =
+internal val AGGREGATE_METRICS_ADDED_IN_SDK_EXT_10: Set<AggregateMetric<*>> =
     setOf(
         BloodPressureRecord.DIASTOLIC_AVG,
         BloodPressureRecord.DIASTOLIC_MAX,
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/BloodPressureAggregationExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/BloodPressureAggregationExtensions.kt
index f397a1c..c629515 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/BloodPressureAggregationExtensions.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/BloodPressureAggregationExtensions.kt
@@ -45,7 +45,7 @@
     dataOriginFilter: Set<DataOrigin>
 ): AggregationResult {
     check(BLOOD_PRESSURE_METRICS.containsAll(bloodPressureMetrics)) {
-        "Invalid set of blood pressure metrics $bloodPressureMetrics"
+        "Invalid set of blood pressure fallback aggregation metrics ${bloodPressureMetrics.map { it.metricKey }}"
     }
 
     if (bloodPressureMetrics.isEmpty()) {
@@ -66,7 +66,7 @@
             BloodPressureRecord.DIASTOLIC_MIN,
             BloodPressureRecord.SYSTOLIC_MAX,
             BloodPressureRecord.SYSTOLIC_MIN -> minMaxMap[metric] = null
-            else -> error("Invalid blood pressure fallback aggregation type ${metric.metricKey}")
+            else -> error("Invalid blood pressure fallback aggregation metric ${metric.metricKey}")
         }
     }
 
@@ -126,12 +126,3 @@
         dataOrigins = dataOrigins
     )
 }
-
-private data class AvgData(var count: Int = 0, var total: Double = 0.0) {
-    operator fun plusAssign(value: Double) {
-        count++
-        total += value
-    }
-
-    fun average() = total / count
-}
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 24f0364..6257f1c 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
@@ -23,9 +23,6 @@
 import androidx.health.connect.client.aggregate.AggregateMetric
 import androidx.health.connect.client.aggregate.AggregationResult
 import androidx.health.connect.client.impl.converters.datatype.RECORDS_CLASS_NAME_MAP
-import androidx.health.connect.client.impl.platform.div
-import androidx.health.connect.client.impl.platform.duration
-import androidx.health.connect.client.impl.platform.minus
 import androidx.health.connect.client.impl.platform.toInstantWithDefaultZoneFallback
 import androidx.health.connect.client.impl.platform.useLocalTime
 import androidx.health.connect.client.records.BloodPressureRecord
@@ -40,8 +37,6 @@
 import androidx.health.connect.client.request.ReadRecordsRequest
 import androidx.health.connect.client.time.TimeRangeFilter
 import java.time.Duration
-import java.time.Instant
-import kotlin.math.max
 import kotlin.reflect.KClass
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
@@ -92,10 +87,12 @@
     return when (recordType) {
         BloodPressureRecord::class ->
             aggregateBloodPressure(recordTypeMetrics, timeRangeFilter, dataOriginFilter)
-        CyclingPedalingCadenceRecord::class -> TODO(reason = "b/326414908")
+        CyclingPedalingCadenceRecord::class ->
+            aggregateCyclingPedalingCadence(recordTypeMetrics, timeRangeFilter, dataOriginFilter)
         NutritionRecord::class -> aggregateNutritionTransFatTotal(timeRangeFilter, dataOriginFilter)
-        SpeedRecord::class -> TODO(reason = "b/326414908")
-        StepsCadenceRecord::class -> TODO(reason = "b/326414908")
+        SpeedRecord::class -> aggregateSpeed(recordTypeMetrics, timeRangeFilter, dataOriginFilter)
+        StepsCadenceRecord::class ->
+            aggregateStepsCadence(recordTypeMetrics, timeRangeFilter, dataOriginFilter)
         else -> error("Invalid record type for aggregation fallback: $recordType")
     }
 }
@@ -156,26 +153,19 @@
     )
 }
 
-internal fun sliceFactor(record: NutritionRecord, timeRangeFilter: TimeRangeFilter): Double {
-    val startTime: Instant
-    val endTime: Instant
-
-    if (timeRangeFilter.useLocalTime()) {
-        val requestStartTime =
-            timeRangeFilter.localStartTime?.toInstantWithDefaultZoneFallback(record.startZoneOffset)
-        val requestEndTime =
-            timeRangeFilter.localEndTime?.toInstantWithDefaultZoneFallback(record.endZoneOffset)
-        startTime = maxOf(record.startTime, requestStartTime ?: record.startTime)
-        endTime = minOf(record.endTime, requestEndTime ?: record.endTime)
-    } else {
-        startTime = maxOf(record.startTime, timeRangeFilter.startTime ?: record.startTime)
-        endTime = minOf(record.endTime, timeRangeFilter.endTime ?: record.endTime)
-    }
-
-    return max(0.0, (endTime - startTime) / record.duration)
-}
-
 internal fun emptyAggregationResult() =
     AggregationResult(longValues = mapOf(), doubleValues = mapOf(), dataOrigins = setOf())
 
-class AggregatedData<T>(var value: T, var dataOrigins: MutableSet<DataOrigin> = mutableSetOf())
+internal class AggregatedData<T>(
+    var value: T,
+    var dataOrigins: MutableSet<DataOrigin> = mutableSetOf()
+)
+
+internal data class AvgData(var count: Int = 0, var total: Double = 0.0) {
+    operator fun plusAssign(value: Double) {
+        count++
+        total += value
+    }
+
+    fun average() = total / count
+}
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/NutritionAggregationExtensions.kt
similarity index 62%
rename from health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/NutritonAggregationExtensions.kt
rename to health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/NutritionAggregationExtensions.kt
index ae0f2d9..9d968db 100644
--- 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/NutritionAggregationExtensions.kt
@@ -21,9 +21,17 @@
 import androidx.annotation.RequiresApi
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.aggregate.AggregationResult
+import androidx.health.connect.client.impl.platform.div
+import androidx.health.connect.client.impl.platform.duration
+import androidx.health.connect.client.impl.platform.minus
+import androidx.health.connect.client.impl.platform.toInstantWithDefaultZoneFallback
+import androidx.health.connect.client.impl.platform.useLocalTime
+import androidx.health.connect.client.records.IntervalRecord
 import androidx.health.connect.client.records.NutritionRecord
 import androidx.health.connect.client.records.metadata.DataOrigin
 import androidx.health.connect.client.time.TimeRangeFilter
+import java.time.Instant
+import kotlin.math.max
 import kotlinx.coroutines.flow.fold
 
 internal suspend fun HealthConnectClient.aggregateNutritionTransFatTotal(
@@ -43,12 +51,12 @@
                 records.filter {
                     it.overlaps(timeRangeFilter) &&
                         it.transFat != null &&
-                        sliceFactor(it, timeRangeFilter) > 0
+                        it.sliceFactor(timeRangeFilter) > 0
                 }
 
             filteredRecords.forEach {
                 currentAggregatedData.value +=
-                    it.transFat!!.inGrams * sliceFactor(it, timeRangeFilter)
+                    it.transFat!!.inGrams * it.sliceFactor(timeRangeFilter)
             }
 
             filteredRecords.mapTo(currentAggregatedData.dataOrigins) { it.metadata.dataOrigin }
@@ -65,3 +73,22 @@
         dataOrigins = aggregatedData.dataOrigins
     )
 }
+
+internal fun IntervalRecord.sliceFactor(timeRangeFilter: TimeRangeFilter): Double {
+    val startTime: Instant
+    val endTime: Instant
+
+    if (timeRangeFilter.useLocalTime()) {
+        val requestStartTime =
+            timeRangeFilter.localStartTime?.toInstantWithDefaultZoneFallback(startZoneOffset)
+        val requestEndTime =
+            timeRangeFilter.localEndTime?.toInstantWithDefaultZoneFallback(endZoneOffset)
+        startTime = maxOf(this.startTime, requestStartTime ?: this.startTime)
+        endTime = minOf(this.endTime, requestEndTime ?: this.endTime)
+    } else {
+        startTime = maxOf(this.startTime, timeRangeFilter.startTime ?: this.startTime)
+        endTime = minOf(this.endTime, timeRangeFilter.endTime ?: this.endTime)
+    }
+
+    return max(0.0, (endTime - startTime) / duration)
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensions.kt
new file mode 100644
index 0000000..7ee764f
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/SeriesRecordAggregationExtensions.kt
@@ -0,0 +1,226 @@
+/*
+ * 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.annotation.VisibleForTesting
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.aggregate.AggregationResult
+import androidx.health.connect.client.impl.platform.toInstantWithDefaultZoneFallback
+import androidx.health.connect.client.impl.platform.useLocalTime
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.SeriesRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsCadenceRecord
+import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.time.TimeRangeFilter
+import java.time.Instant
+import java.time.ZoneOffset
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.reflect.KClass
+
+private val RECORDS_TO_AGGREGATE_METRICS_INFO_MAP =
+    mapOf(
+        CyclingPedalingCadenceRecord::class to
+            AggregateMetricsInfo(
+                averageMetric = CyclingPedalingCadenceRecord.RPM_AVG,
+                maxMetric = CyclingPedalingCadenceRecord.RPM_MAX,
+                minMetric = CyclingPedalingCadenceRecord.RPM_MIN
+            ),
+        SpeedRecord::class to
+            AggregateMetricsInfo(
+                averageMetric = SpeedRecord.SPEED_AVG,
+                maxMetric = SpeedRecord.SPEED_MAX,
+                minMetric = SpeedRecord.SPEED_MIN
+            ),
+        StepsCadenceRecord::class to
+            AggregateMetricsInfo(
+                averageMetric = StepsCadenceRecord.RATE_AVG,
+                maxMetric = StepsCadenceRecord.RATE_MAX,
+                minMetric = StepsCadenceRecord.RATE_MIN
+            )
+    )
+
+internal suspend fun HealthConnectClient.aggregateCyclingPedalingCadence(
+    metrics: Set<AggregateMetric<*>>,
+    timeRangeFilter: TimeRangeFilter,
+    dataOriginFilter: Set<DataOrigin>
+) =
+    aggregateSeriesRecord(
+        recordType = CyclingPedalingCadenceRecord::class,
+        aggregateMetrics = metrics,
+        timeRangeFilter = timeRangeFilter,
+        dataOriginFilter = dataOriginFilter
+    ) {
+        samples.map { SampleInfo(time = it.time, value = it.revolutionsPerMinute) }
+    }
+
+internal suspend fun HealthConnectClient.aggregateSpeed(
+    metrics: Set<AggregateMetric<*>>,
+    timeRangeFilter: TimeRangeFilter,
+    dataOriginFilter: Set<DataOrigin>
+) =
+    aggregateSeriesRecord(
+        recordType = SpeedRecord::class,
+        aggregateMetrics = metrics,
+        timeRangeFilter = timeRangeFilter,
+        dataOriginFilter = dataOriginFilter
+    ) {
+        samples.map { SampleInfo(time = it.time, value = it.speed.inMetersPerSecond) }
+    }
+
+internal suspend fun HealthConnectClient.aggregateStepsCadence(
+    metrics: Set<AggregateMetric<*>>,
+    timeRangeFilter: TimeRangeFilter,
+    dataOriginFilter: Set<DataOrigin>
+) =
+    aggregateSeriesRecord(
+        recordType = StepsCadenceRecord::class,
+        aggregateMetrics = metrics,
+        timeRangeFilter = timeRangeFilter,
+        dataOriginFilter = dataOriginFilter
+    ) {
+        samples.map { SampleInfo(time = it.time, value = it.rate) }
+    }
+
+@VisibleForTesting
+internal suspend inline fun <reified R : SeriesRecord<*>> HealthConnectClient.aggregateSeriesRecord(
+    recordType: KClass<R>,
+    aggregateMetrics: Set<AggregateMetric<*>>,
+    timeRangeFilter: TimeRangeFilter,
+    dataOriginFilter: Set<DataOrigin>,
+    crossinline getSampleInfo: R.() -> List<SampleInfo>
+): AggregationResult {
+    val aggregateInfo =
+        RECORDS_TO_AGGREGATE_METRICS_INFO_MAP[recordType]
+            ?: throw IllegalArgumentException("Non supported fallback series record $recordType")
+
+    check(
+        setOf(aggregateInfo.averageMetric, aggregateInfo.minMetric, aggregateInfo.maxMetric)
+            .containsAll(aggregateMetrics)
+    ) {
+        "Invalid set of metrics ${aggregateMetrics.map { it.metricKey }}"
+    }
+
+    if (aggregateMetrics.isEmpty()) {
+        return emptyAggregationResult()
+    }
+
+    val readRecordsFlow =
+        readRecordsFlow(recordType, timeRangeFilter.withBufferedStart(), dataOriginFilter)
+
+    val avgData = AvgData()
+    var min: Double? = null
+    var max: Double? = null
+
+    val dataOrigins = mutableSetOf<DataOrigin>()
+
+    readRecordsFlow.collect { records ->
+        records
+            .asSequence()
+            .map {
+                RecordInfo(
+                    dataOrigin = it.metadata.dataOrigin,
+                    samples =
+                        it.getSampleInfo().filter { sample ->
+                            sample.isWithin(
+                                timeRangeFilter = timeRangeFilter,
+                                zoneOffset = it.startZoneOffset
+                            )
+                        }
+                )
+            }
+            .filter { it.samples.isNotEmpty() }
+            .forEach { recordInfo ->
+                recordInfo.samples.forEach {
+                    avgData += it.value
+                    min = min(min ?: it.value, it.value)
+                    max = max(max ?: it.value, it.value)
+                }
+                dataOrigins += recordInfo.dataOrigin
+            }
+    }
+
+    if (dataOrigins.isEmpty()) {
+        return emptyAggregationResult()
+    }
+
+    val doubleValues = buildMap {
+        for (metric in aggregateMetrics) {
+            val result =
+                when (metric) {
+                    aggregateInfo.averageMetric -> avgData.average()
+                    aggregateInfo.maxMetric -> max!!
+                    aggregateInfo.minMetric -> min!!
+                    else -> error("Invalid fallback aggregation metric ${metric.metricKey}")
+                }
+            put(metric.metricKey, result)
+        }
+    }
+
+    return AggregationResult(
+        longValues = mapOf(),
+        doubleValues = doubleValues,
+        dataOrigins = dataOrigins
+    )
+}
+
+@VisibleForTesting
+internal data class AggregateMetricsInfo<T : Any>(
+    val averageMetric: AggregateMetric<T>,
+    val minMetric: AggregateMetric<T>,
+    val maxMetric: AggregateMetric<T>
+)
+
+@VisibleForTesting
+internal data class RecordInfo(val dataOrigin: DataOrigin, val samples: List<SampleInfo>)
+
+@VisibleForTesting
+internal data class SampleInfo(val time: Instant, val value: Double) {
+    fun isWithin(timeRangeFilter: TimeRangeFilter, zoneOffset: ZoneOffset?): Boolean {
+        if (timeRangeFilter.useLocalTime()) {
+            if (
+                timeRangeFilter.localStartTime != null &&
+                    time.isBefore(
+                        timeRangeFilter.localStartTime.toInstantWithDefaultZoneFallback(zoneOffset)
+                    )
+            ) {
+                return false
+            }
+            if (
+                timeRangeFilter.localEndTime != null &&
+                    !time.isBefore(
+                        timeRangeFilter.localEndTime.toInstantWithDefaultZoneFallback(zoneOffset)
+                    )
+            ) {
+                return false
+            }
+            return true
+        }
+        if (timeRangeFilter.startTime != null && time.isBefore(timeRangeFilter.startTime)) {
+            return false
+        }
+        if (timeRangeFilter.endTime != null && !time.isBefore(timeRangeFilter.endTime)) {
+            return false
+        }
+        return true
+    }
+}