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
+ }
+}