Merge "Add aggregation fallback and nutrition transfat total aggregation." into androidx-main
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
index f25c14e..926ed7a 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImplTest.kt
@@ -64,8 +64,7 @@
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@MediumTest
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
-// Comment the SDK suppress to run on emulators lower than U.
-@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, codeName = "UpsideDownCake")
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class HealthConnectClientUpsideDownImplTest {
private companion object {
@@ -88,7 +87,6 @@
.filter { it.startsWith(PERMISSION_PREFIX) }
.toTypedArray()
- // Grant every permission as deletion by id checks for every permission
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(*allHealthPermissions)
@@ -167,10 +165,10 @@
)
assertThat(
- healthConnectClient
- .readRecords(ReadRecordsRequest(StepsRecord::class, TimeRangeFilter.none()))
- .records
- )
+ healthConnectClient
+ .readRecords(ReadRecordsRequest(StepsRecord::class, TimeRangeFilter.none()))
+ .records
+ )
.containsExactly(initialRecords[0])
}
@@ -208,10 +206,10 @@
)
assertThat(
- healthConnectClient
- .readRecords(ReadRecordsRequest(StepsRecord::class, TimeRangeFilter.none()))
- .records
- )
+ healthConnectClient
+ .readRecords(ReadRecordsRequest(StepsRecord::class, TimeRangeFilter.none()))
+ .records
+ )
.containsExactly(initialRecords[1])
}
@@ -336,10 +334,10 @@
endTime = START_TIME + 30.seconds,
endZoneOffset = ZoneOffset.UTC,
samples =
- listOf(
- HeartRateRecord.Sample(START_TIME, 57L),
- HeartRateRecord.Sample(START_TIME + 15.seconds, 120L)
- )
+ listOf(
+ HeartRateRecord.Sample(START_TIME, 57L),
+ HeartRateRecord.Sample(START_TIME + 15.seconds, 120L)
+ )
),
HeartRateRecord(
startTime = START_TIME + 1.minutes,
@@ -347,10 +345,10 @@
endTime = START_TIME + 1.minutes + 30.seconds,
endZoneOffset = ZoneOffset.UTC,
samples =
- listOf(
- HeartRateRecord.Sample(START_TIME + 1.minutes, 47L),
- HeartRateRecord.Sample(START_TIME + 1.minutes + 15.seconds, 48L)
- )
+ listOf(
+ HeartRateRecord.Sample(START_TIME + 1.minutes, 47L),
+ HeartRateRecord.Sample(START_TIME + 1.minutes + 15.seconds, 48L)
+ )
),
NutritionRecord(
startTime = START_TIME,
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
new file mode 100644
index 0000000..75435a1
--- /dev/null
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensionsTest.kt
@@ -0,0 +1,548 @@
+/*
+ * 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.NutritionRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.records.metadata.DataOrigin
+import androidx.health.connect.client.time.TimeRangeFilter
+import androidx.health.connect.client.units.Mass
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.rule.GrantPermissionRule
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import kotlinx.coroutines.flow.fold
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+@MediumTest
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, codeName = "UpsideDownCake")
+class HealthConnectClientAggregationExtensionsTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val healthConnectClient: HealthConnectClient =
+ HealthConnectClientUpsideDownImpl(context)
+
+ private companion object {
+ private val START_TIME =
+ LocalDate.now().minusDays(5).atStartOfDay().toInstant(ZoneOffset.UTC)
+ }
+
+ @get:Rule
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ HealthPermission.getWritePermission(NutritionRecord::class),
+ HealthPermission.getReadPermission(NutritionRecord::class),
+ HealthPermission.getWritePermission(StepsRecord::class),
+ HealthPermission.getReadPermission(StepsRecord::class)
+ )
+
+ @After
+ fun tearDown() = runTest {
+ healthConnectClient.deleteRecords(NutritionRecord::class, TimeRangeFilter.none())
+ healthConnectClient.deleteRecords(StepsRecord::class, TimeRangeFilter.none())
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_noFilters() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.3),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 2.minutes,
+ endTime = START_TIME + 3.minutes,
+ transFat = null,
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 4.minutes,
+ endTime = START_TIME + 5.minutes,
+ transFat = Mass.grams(0.4),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 6.minutes,
+ endTime = START_TIME + 7.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 8.minutes,
+ endTime = START_TIME + 9.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+ )
+ )
+
+ val aggregationResult =
+ healthConnectClient.aggregateNutritionTransFatTotal(TimeRangeFilter.none(), emptySet())
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL]).isEqualTo(Mass.grams(1.7))
+ assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_instantTimeRangeFilter() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.3),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 2.minutes,
+ endTime = START_TIME + 3.minutes,
+ transFat = null,
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 4.minutes,
+ endTime = START_TIME + 5.minutes,
+ transFat = Mass.grams(0.4),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 6.minutes,
+ endTime = START_TIME + 7.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 8.minutes,
+ endTime = START_TIME + 9.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ START_TIME + 30.seconds,
+ START_TIME + 6.minutes + 45.seconds
+ ), emptySet()
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
+ assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordEndTime() =
+ runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.3),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 2.minutes,
+ endTime = START_TIME + 3.minutes,
+ transFat = Mass.grams(0.4),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ START_TIME + 1.minutes,
+ START_TIME + 2.minutes
+ ), emptySet()
+ )
+
+ assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+ assertThat(aggregationResult.dataOrigins).isEmpty()
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_filterStartTimeRecordStartTime() =
+ runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.3),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 2.minutes,
+ endTime = START_TIME + 3.minutes,
+ transFat = Mass.grams(0.4),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ START_TIME,
+ START_TIME + 2.minutes
+ ), emptySet()
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.3))
+ assertThat(aggregationResult.dataOrigins)
+ .containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_instantTimeRangeFilter_recordRangeLargerThanQuery() =
+ runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ START_TIME + 15.seconds,
+ START_TIME + 45.seconds
+ ), emptySet()
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.25))
+ assertThat(aggregationResult.dataOrigins)
+ .containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_localTimeRangeFilter() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.3),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 2.minutes,
+ endTime = START_TIME + 3.minutes,
+ transFat = null,
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME - 2.hours + 4.minutes,
+ endTime = START_TIME + 5.minutes,
+ transFat = Mass.grams(0.4),
+ startZoneOffset = ZoneOffset.ofHours(2),
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ NutritionRecord(
+ startTime = START_TIME + 3.hours + 6.minutes,
+ endTime = START_TIME + 3.hours + 7.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.ofHours(-3),
+ endZoneOffset = ZoneOffset.ofHours(-3)
+ ),
+ NutritionRecord(
+ startTime = START_TIME - 4.hours + 8.minutes,
+ endTime = START_TIME - 4.hours + 9.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.ofHours(4),
+ endZoneOffset = ZoneOffset.ofHours(4)
+ )
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ LocalDateTime.ofInstant(START_TIME + 30.seconds, ZoneOffset.UTC),
+ LocalDateTime.ofInstant(START_TIME + 6.minutes + 45.seconds, ZoneOffset.UTC)
+ ), emptySet()
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.15 + 0.4 + 0.375))
+ assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_localTimeRangeFilter_recordRangeLargerThanQuery() =
+ runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ LocalDateTime.ofInstant(
+ START_TIME - 2.hours + 15.seconds,
+ ZoneOffset.ofHours(2)
+ ),
+ LocalDateTime.ofInstant(
+ START_TIME - 2.hours + 45.seconds,
+ ZoneOffset.ofHours(2)
+ )
+ ), emptySet()
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.25))
+ assertThat(aggregationResult.dataOrigins)
+ .containsExactly(DataOrigin(context.packageName))
+ }
+
+ // TODO(b/337195270): Test with data origins from multiple apps
+ @Test
+ fun aggregateNutritionTransFatTotal_insertedDataOriginFilter() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.none(),
+ setOf(DataOrigin(context.packageName))
+ )
+
+ assertThat(aggregationResult[NutritionRecord.TRANS_FAT_TOTAL])
+ .isEqualTo(Mass.grams(0.5))
+ assertThat(aggregationResult.dataOrigins).containsExactly(DataOrigin(context.packageName))
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_timeRangeFilterOutOfBounds() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.after(START_TIME + 2.minutes),
+ emptySet()
+ )
+
+ assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+ assertThat(aggregationResult.dataOrigins).isEmpty()
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_recordStartTimeWithNegativeZoneOffset() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 60.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.ofHours(-2),
+ endZoneOffset = ZoneOffset.UTC
+ )
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.between(
+ LocalDateTime.ofInstant(START_TIME, ZoneOffset.UTC),
+ LocalDateTime.ofInstant(START_TIME + 60.minutes, ZoneOffset.UTC)
+ ), emptySet()
+ )
+
+ assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+ assertThat(aggregationResult.dataOrigins).isEmpty()
+ }
+
+ @Test
+ fun aggregateNutritionTransFatTotal_nonExistingDataOriginFilter() = runTest {
+ healthConnectClient.insertRecords(
+ listOf(
+ NutritionRecord(
+ startTime = START_TIME,
+ endTime = START_TIME + 1.minutes,
+ transFat = Mass.grams(0.5),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ ),
+ )
+ )
+
+ val aggregationResult = healthConnectClient.aggregateNutritionTransFatTotal(
+ TimeRangeFilter.none(),
+ setOf(DataOrigin("some random package name"))
+ )
+
+ assertThat(NutritionRecord.TRANS_FAT_TOTAL in aggregationResult).isFalse()
+ assertThat(aggregationResult.dataOrigins).isEmpty()
+ }
+
+ @Test
+ fun readRecordsFlow_noFilters_readsAllInsertedRecords() = runTest {
+ insertManyStepsRecords()
+
+ val count = healthConnectClient.readRecordsFlow(
+ StepsRecord::class,
+ TimeRangeFilter.none(),
+ emptySet()
+ ).fold(0) { currentCount, records ->
+ currentCount + records.size
+ }
+
+ assertThat(count).isEqualTo(10_000L)
+ }
+
+ @Test
+ fun readRecordsFlow_timeRangeFilter_readsFilteredRecords() = runTest {
+ assumeTrue(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+ insertManyStepsRecords()
+
+ val count = healthConnectClient.readRecordsFlow(
+ StepsRecord::class,
+ TimeRangeFilter.between(START_TIME + 10_000.seconds, START_TIME + 90_000.seconds),
+ emptySet()
+ ).fold(0) { currentCount, records ->
+ currentCount + records.size
+ }
+
+ assertThat(count).isEqualTo(8_000L)
+ }
+
+ // TODO(b/337195270): Test with data origins from multiple apps
+ @Test
+ fun readRecordsFlow_insertedDataOriginFilter_readsAllInsertedRecords() = runTest {
+ insertManyStepsRecords()
+
+ val count = healthConnectClient.readRecordsFlow(
+ StepsRecord::class,
+ TimeRangeFilter.none(),
+ setOf(DataOrigin(context.packageName))
+ ).fold(0) { currentCount, records ->
+ currentCount + records.size
+ }
+
+ assertThat(count).isEqualTo(10_000L)
+ }
+
+ @Test
+ fun readRecordsFlow_nonExistingDataOriginFilter_doesNotReadAnyRecord() = runTest {
+ insertManyStepsRecords()
+
+ val count = healthConnectClient.readRecordsFlow(
+ StepsRecord::class,
+ TimeRangeFilter.none(),
+ setOf(DataOrigin("some random package name"))
+ ).fold(0) { currentCount, records ->
+ currentCount + records.size
+ }
+
+ assertThat(count).isEqualTo(0L)
+ }
+
+ private suspend fun insertManyStepsRecords() {
+ // Insert a large number of step records, bigger than the default page size
+ for (i in 0..9) {
+ healthConnectClient.insertRecords(List(1000) {
+ val startTime = START_TIME + (i * 10_000 + it * 10).seconds
+ StepsRecord(
+ startTime = startTime,
+ endTime = startTime + 5.seconds,
+ count = 10L,
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+ })
+ }
+ }
+
+ private val Int.seconds: Duration
+ get() = Duration.ofSeconds(this.toLong())
+
+ private val Int.minutes: Duration
+ get() = Duration.ofMinutes(this.toLong())
+
+ private val Int.hours: Duration
+ get() = Duration.ofHours(this.toLong())
+}
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/RequestConvertersTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/RequestConvertersTest.kt
index d2edf4b..859cbbc 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/RequestConvertersTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/RequestConvertersTest.kt
@@ -25,6 +25,9 @@
import android.health.connect.datatypes.StepsRecord as PlatformStepsRecord
import android.health.connect.datatypes.WheelchairPushesRecord as PlatformWheelchairPushesRecord
import android.os.Build
+import androidx.health.connect.client.impl.platform.request.toAggregationType
+import androidx.health.connect.client.impl.platform.request.toPlatformRequest
+import androidx.health.connect.client.impl.platform.request.toPlatformTimeRangeFilter
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.NutritionRecord
import androidx.health.connect.client.records.StepsRecord
diff --git a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/ResponseConvertersTest.kt b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/ResponseConvertersTest.kt
index fffd43a..b7bbc3d 100644
--- a/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/ResponseConvertersTest.kt
+++ b/health/connect/connect-client/src/androidTest/java/androidx/health/connect/client/impl/platform/records/ResponseConvertersTest.kt
@@ -23,8 +23,13 @@
import android.health.connect.datatypes.units.Power as PlatformPower
import android.health.connect.datatypes.units.Volume as PlatformVolume
import android.os.Build
+import android.os.ext.SdkExtensions
import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.impl.platform.response.buildAggregationResult
+import androidx.health.connect.client.impl.platform.response.getDoubleMetricValues
+import androidx.health.connect.client.impl.platform.response.getLongMetricValues
import androidx.health.connect.client.records.BasalMetabolicRateRecord
+import androidx.health.connect.client.records.BloodPressureRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.FloorsClimbedRecord
@@ -32,6 +37,8 @@
import androidx.health.connect.client.records.HydrationRecord
import androidx.health.connect.client.records.NutritionRecord
import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.WeightRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
@@ -39,6 +46,7 @@
import com.google.common.truth.Correspondence
import com.google.common.truth.Truth.assertThat
import java.time.Duration
+import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -56,8 +64,10 @@
fun buildAggregationResult() {
val aggregationResult =
buildAggregationResult(
- metrics =
- setOf(HeartRateRecord.BPM_MIN, ExerciseSessionRecord.EXERCISE_DURATION_TOTAL),
+ metrics = setOf(
+ HeartRateRecord.BPM_MIN,
+ ExerciseSessionRecord.EXERCISE_DURATION_TOTAL
+ ),
aggregationValueGetter = { aggregationType ->
when (aggregationType) {
PlatformHeartRateRecord.BPM_MIN -> 53L
@@ -72,8 +82,10 @@
PlatformDataOriginBuilder().setPackageName("HR App1").build(),
PlatformDataOriginBuilder().setPackageName("HR App2").build()
)
+
PlatformExerciseSessionRecord.EXERCISE_DURATION_TOTAL ->
setOf(PlatformDataOriginBuilder().setPackageName("Workout app").build())
+
else -> emptySet()
}
}
@@ -159,6 +171,17 @@
}
@Test
+ fun getDoubleMetricValue_convertsMassToKilograms() {
+ val metricValues = getDoubleMetricValues(
+ mapOf(
+ WeightRecord.WEIGHT_MAX as AggregateMetric<Any> to PlatformMass.fromGrams(100_000.0)
+ )
+ )
+
+ assertThat(metricValues).containsExactly(WeightRecord.WEIGHT_MAX.metricKey, 100.0)
+ }
+
+ @Test
fun getDoubleMetricValues_convertsPowerToWatts() {
val metricValues =
getDoubleMetricValues(
@@ -170,6 +193,34 @@
}
@Test
+ fun getDoubleMetricValues_convertsPressureToMillimetersOfMercury() {
+ assumeTrue(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+ val metricValues = getDoubleMetricValues(
+ mapOf(
+ BloodPressureRecord.SYSTOLIC_MAX as AggregateMetric<Any> to
+ PlatformPressure.fromMillimetersOfMercury(
+ 120.0
+ )
+ )
+ )
+
+ assertThat(metricValues).containsExactly(BloodPressureRecord.SYSTOLIC_MAX.metricKey, 120.0)
+ }
+
+ @Test
+ fun getDoubleMetricValues_convertsVelocityToMetersPerSecond() {
+ assumeTrue(SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10)
+ val metricValues = getDoubleMetricValues(
+ mapOf(
+ SpeedRecord.SPEED_AVG as AggregateMetric<Any> to
+ PlatformVelocity.fromMetersPerSecond(2.8)
+ )
+ )
+
+ assertThat(metricValues).containsExactly(SpeedRecord.SPEED_AVG.metricKey, 2.8)
+ }
+
+ @Test
fun getDoubleMetricValues_convertsVolumeToLiters() {
val metricValues =
getDoubleMetricValues(
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
index eac2033..5b6d17e 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientUpsideDownImpl.kt
@@ -38,14 +38,16 @@
import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
import androidx.health.connect.client.changes.DeletionChange
import androidx.health.connect.client.changes.UpsertionChange
-import androidx.health.connect.client.impl.platform.records.toPlatformLocalTimeRangeFilter
+import androidx.health.connect.client.impl.platform.aggregate.aggregateFallback
+import androidx.health.connect.client.impl.platform.aggregate.plus
import androidx.health.connect.client.impl.platform.records.toPlatformRecord
import androidx.health.connect.client.impl.platform.records.toPlatformRecordClass
-import androidx.health.connect.client.impl.platform.records.toPlatformRequest
-import androidx.health.connect.client.impl.platform.records.toPlatformTimeRangeFilter
import androidx.health.connect.client.impl.platform.records.toSdkRecord
-import androidx.health.connect.client.impl.platform.records.toSdkResponse
+import androidx.health.connect.client.impl.platform.request.toPlatformLocalTimeRangeFilter
+import androidx.health.connect.client.impl.platform.request.toPlatformRequest
+import androidx.health.connect.client.impl.platform.request.toPlatformTimeRangeFilter
import androidx.health.connect.client.impl.platform.response.toKtResponse
+import androidx.health.connect.client.impl.platform.response.toSdkResponse
import androidx.health.connect.client.impl.platform.toKtException
import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
import androidx.health.connect.client.records.Record
@@ -201,31 +203,33 @@
}
override suspend fun aggregate(request: AggregateRequest): AggregationResult {
- return wrapPlatformException {
- suspendCancellableCoroutine { continuation ->
- healthConnectManager.aggregate(
- request.toPlatformRequest(),
- executor,
- continuation.asOutcomeReceiver()
- )
- }
+ val platformResponse = wrapPlatformException {
+ suspendCancellableCoroutine { continuation ->
+ healthConnectManager.aggregate(
+ request.toPlatformRequest(),
+ executor,
+ continuation.asOutcomeReceiver()
+ )
}
+ }
.toSdkResponse(request.metrics)
+ val fallbackResponse = aggregateFallback(request)
+ return platformResponse + fallbackResponse
}
override suspend fun aggregateGroupByDuration(
request: AggregateGroupByDurationRequest
): List<AggregationResultGroupedByDuration> {
return wrapPlatformException {
- suspendCancellableCoroutine { continuation ->
- healthConnectManager.aggregateGroupByDuration(
- request.toPlatformRequest(),
- request.timeRangeSlicer,
- executor,
- continuation.asOutcomeReceiver()
- )
- }
+ suspendCancellableCoroutine { continuation ->
+ healthConnectManager.aggregateGroupByDuration(
+ request.toPlatformRequest(),
+ request.timeRangeSlicer,
+ executor,
+ continuation.asOutcomeReceiver()
+ )
}
+ }
.map { it.toSdkResponse(request.metrics) }
}
@@ -233,19 +237,19 @@
request: AggregateGroupByPeriodRequest
): List<AggregationResultGroupedByPeriod> {
return wrapPlatformException {
- suspendCancellableCoroutine { continuation ->
- healthConnectManager.aggregateGroupByPeriod(
- request.toPlatformRequest(),
- request.timeRangeSlicer,
- executor,
- continuation.asOutcomeReceiver()
- )
- }
+ suspendCancellableCoroutine { continuation ->
+ healthConnectManager.aggregateGroupByPeriod(
+ request.toPlatformRequest(),
+ request.timeRangeSlicer,
+ executor,
+ continuation.asOutcomeReceiver()
+ )
}
+ }
.mapIndexed { index, platformResponse ->
if (
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10 ||
- (request.timeRangeSlicer.months == 0 && request.timeRangeSlicer.years == 0)
+ (request.timeRangeSlicer.months == 0 && request.timeRangeSlicer.years == 0)
) {
platformResponse.toSdkResponse(request.metrics)
} else {
@@ -261,11 +265,11 @@
metrics = request.metrics,
bucketStartTime = bucketStartTime,
bucketEndTime =
- if (requestTimeRangeFilter.endTime!!.isBefore(bucketEndTime)) {
- requestTimeRangeFilter.endTime!!
- } else {
- bucketEndTime
- }
+ if (requestTimeRangeFilter.endTime!!.isBefore(bucketEndTime)) {
+ requestTimeRangeFilter.endTime!!
+ } else {
+ bucketEndTime
+ }
)
}
}
@@ -273,14 +277,14 @@
override suspend fun getChangesToken(request: ChangesTokenRequest): String {
return wrapPlatformException {
- suspendCancellableCoroutine { continuation ->
- healthConnectManager.getChangeLogToken(
- request.toPlatformRequest(),
- executor,
- continuation.asOutcomeReceiver()
- )
- }
+ suspendCancellableCoroutine { continuation ->
+ healthConnectManager.getChangeLogToken(
+ request.toPlatformRequest(),
+ executor,
+ continuation.asOutcomeReceiver()
+ )
}
+ }
.token
}
@@ -324,7 +328,7 @@
for (i in it.requestedPermissions.indices) {
if (
it.requestedPermissions[i].startsWith(PERMISSION_PREFIX) &&
- it.requestedPermissionsFlags[i] and REQUESTED_PERMISSION_GRANTED > 0
+ it.requestedPermissionsFlags[i] and REQUESTED_PERMISSION_GRANTED > 0
) {
add(it.requestedPermissions[i])
}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/TimeExtensions.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/TimeExtensions.kt
new file mode 100644
index 0000000..a8705ff
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/TimeExtensions.kt
@@ -0,0 +1,47 @@
+/*
+ * 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
+
+import androidx.health.connect.client.records.IntervalRecord
+import androidx.health.connect.client.time.TimeRangeFilter
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+
+internal operator fun Duration.div(divisor: Duration): Double {
+ if (divisor.isZero) {
+ return 0.0
+ }
+ return toMillis().toDouble() / divisor.toMillis()
+}
+
+internal operator fun Instant.minus(other: Instant): Duration {
+ return Duration.between(other, this)
+}
+
+internal fun TimeRangeFilter.useLocalTime(): Boolean {
+ return localStartTime != null || localEndTime != null
+}
+
+internal fun LocalDateTime.toInstantWithDefaultZoneFallback(zoneOffset: ZoneOffset?): Instant {
+ return atZone(zoneOffset ?: ZoneId.systemDefault()).toInstant()
+}
+
+internal val IntervalRecord.duration: Duration
+ get() = endTime - startTime
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
new file mode 100644
index 0000000..f323864
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationExtensions.kt
@@ -0,0 +1,75 @@
+/*
+ * 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 android.os.Build
+import android.os.ext.SdkExtensions
+import androidx.annotation.RequiresApi
+import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.aggregate.AggregationResult
+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.request.AggregateRequest
+
+internal val AggregateRequest.platformMetrics: Set<AggregateMetric<*>>
+ get() {
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ return metrics
+ }
+ return metrics.filterNot { it in SDK_EXT_10_AGGREGATE_METRICS }.toSet()
+ }
+
+internal val AggregateRequest.fallbackMetrics: Set<AggregateMetric<*>>
+ get() {
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ return emptySet()
+ }
+ return metrics.filter { it in SDK_EXT_10_AGGREGATE_METRICS }.toSet()
+ }
+
+internal operator fun AggregationResult.plus(other: AggregationResult): AggregationResult {
+ return AggregationResult(
+ longValues + other.longValues,
+ doubleValues + other.doubleValues,
+ dataOrigins + other.dataOrigins
+ )
+}
+
+internal val SDK_EXT_10_AGGREGATE_METRICS: Set<AggregateMetric<*>> =
+ setOf(
+ BloodPressureRecord.DIASTOLIC_AVG,
+ BloodPressureRecord.DIASTOLIC_MAX,
+ BloodPressureRecord.DIASTOLIC_MIN,
+ BloodPressureRecord.SYSTOLIC_AVG,
+ BloodPressureRecord.SYSTOLIC_MAX,
+ BloodPressureRecord.SYSTOLIC_MIN,
+ CyclingPedalingCadenceRecord.RPM_AVG,
+ CyclingPedalingCadenceRecord.RPM_MAX,
+ CyclingPedalingCadenceRecord.RPM_MIN,
+ NutritionRecord.TRANS_FAT_TOTAL,
+ SpeedRecord.SPEED_AVG,
+ SpeedRecord.SPEED_MAX,
+ SpeedRecord.SPEED_MIN,
+ StepsCadenceRecord.RATE_AVG,
+ StepsCadenceRecord.RATE_MAX,
+ StepsCadenceRecord.RATE_MIN
+ )
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/AggregationMappings.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationMappings.kt
similarity index 74%
rename from health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/AggregationMappings.kt
rename to health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationMappings.kt
index 42d97d3..1d45df8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/AggregationMappings.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/AggregationMappings.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 The Android Open Source Project
+ * 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.
@@ -17,7 +17,7 @@
@file:RestrictTo(RestrictTo.Scope.LIBRARY)
@file:RequiresApi(api = 34)
-package androidx.health.connect.client.impl.platform.records
+package androidx.health.connect.client.impl.platform.aggregate
import android.health.connect.datatypes.ActiveCaloriesBurnedRecord as PlatformActiveCaloriesBurnedRecord
import android.health.connect.datatypes.AggregationType as PlatformAggregateMetric
@@ -39,11 +39,24 @@
import android.health.connect.datatypes.units.Mass as PlatformMass
import android.health.connect.datatypes.units.Power as PlatformPower
import android.health.connect.datatypes.units.Volume as PlatformVolume
+import android.os.Build
+import android.os.ext.SdkExtensions
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.impl.platform.records.PlatformBloodPressureRecord
+import androidx.health.connect.client.impl.platform.records.PlatformCyclingPedalingCadenceRecord
+import androidx.health.connect.client.impl.platform.records.PlatformExerciseSessionRecord
+import androidx.health.connect.client.impl.platform.records.PlatformPressure
+import androidx.health.connect.client.impl.platform.records.PlatformRestingHeartRateRecord
+import androidx.health.connect.client.impl.platform.records.PlatformSleepSessionRecord
+import androidx.health.connect.client.impl.platform.records.PlatformSpeedRecord
+import androidx.health.connect.client.impl.platform.records.PlatformStepsCadenceRecord
+import androidx.health.connect.client.impl.platform.records.PlatformVelocity
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.BasalMetabolicRateRecord
+import androidx.health.connect.client.records.BloodPressureRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
@@ -55,6 +68,8 @@
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SleepSessionRecord
+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.TotalCaloriesBurnedRecord
import androidx.health.connect.client.records.WeightRecord
@@ -63,14 +78,31 @@
import androidx.health.connect.client.units.Length
import androidx.health.connect.client.units.Mass
import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Pressure
+import androidx.health.connect.client.units.Velocity
import androidx.health.connect.client.units.Volume
import java.time.Duration
+private val DOUBLE_AGGREGATION_METRIC_TYPE_SDK_EXT_10_PAIRS =
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ arrayOf(
+ CyclingPedalingCadenceRecord.RPM_AVG to PlatformCyclingPedalingCadenceRecord.RPM_AVG,
+ CyclingPedalingCadenceRecord.RPM_MAX to PlatformCyclingPedalingCadenceRecord.RPM_MAX,
+ CyclingPedalingCadenceRecord.RPM_MIN to PlatformCyclingPedalingCadenceRecord.RPM_MIN,
+ StepsCadenceRecord.RATE_AVG to PlatformStepsCadenceRecord.STEPS_CADENCE_RATE_AVG,
+ StepsCadenceRecord.RATE_MAX to PlatformStepsCadenceRecord.STEPS_CADENCE_RATE_MAX,
+ StepsCadenceRecord.RATE_MIN to PlatformStepsCadenceRecord.STEPS_CADENCE_RATE_MIN
+ )
+ } else {
+ emptyArray()
+ }
+
internal val DOUBLE_AGGREGATION_METRIC_TYPE_MAP:
Map<AggregateMetric<Double>, PlatformAggregateMetric<Double>> =
mapOf(
FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL to
PlatformFloorsClimbedRecord.FLOORS_CLIMBED_TOTAL,
+ *DOUBLE_AGGREGATION_METRIC_TYPE_SDK_EXT_10_PAIRS
)
internal val DURATION_AGGREGATION_METRIC_TYPE_MAP:
@@ -163,7 +195,14 @@
NutritionRecord.VITAMIN_D_TOTAL to PlatformNutritionRecord.VITAMIN_D_TOTAL,
NutritionRecord.VITAMIN_E_TOTAL to PlatformNutritionRecord.VITAMIN_E_TOTAL,
NutritionRecord.VITAMIN_K_TOTAL to PlatformNutritionRecord.VITAMIN_K_TOTAL,
- NutritionRecord.ZINC_TOTAL to PlatformNutritionRecord.ZINC_TOTAL
+ NutritionRecord.ZINC_TOTAL to PlatformNutritionRecord.ZINC_TOTAL,
+ *if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ arrayOf(
+ NutritionRecord.TRANS_FAT_TOTAL to PlatformNutritionRecord.TRANS_FAT_TOTAL
+ )
+ } else {
+ emptyArray()
+ }
)
internal val KILOGRAMS_AGGREGATION_METRIC_TYPE_MAP:
@@ -182,6 +221,33 @@
PowerRecord.POWER_MIN to PlatformPowerRecord.POWER_MIN,
)
+internal val PRESSURE_AGGREGATION_METRIC_TYPE_MAP:
+ Map<AggregateMetric<Pressure>, PlatformAggregateMetric<PlatformPressure>> =
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ arrayOf(
+ BloodPressureRecord.DIASTOLIC_AVG to PlatformBloodPressureRecord.DIASTOLIC_AVG,
+ BloodPressureRecord.DIASTOLIC_MAX to PlatformBloodPressureRecord.DIASTOLIC_MAX,
+ BloodPressureRecord.DIASTOLIC_MIN to PlatformBloodPressureRecord.DIASTOLIC_MIN,
+ BloodPressureRecord.SYSTOLIC_AVG to PlatformBloodPressureRecord.SYSTOLIC_AVG,
+ BloodPressureRecord.SYSTOLIC_MAX to PlatformBloodPressureRecord.SYSTOLIC_MAX,
+ BloodPressureRecord.SYSTOLIC_MIN to PlatformBloodPressureRecord.SYSTOLIC_MIN
+ )
+ } else {
+ emptyArray()
+ }.toMap()
+
+internal val VELOCITY_AGGREGATION_METRIC_TYPE_MAP:
+ Map<AggregateMetric<Velocity>, PlatformAggregateMetric<PlatformVelocity>> =
+ if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 10) {
+ arrayOf(
+ SpeedRecord.SPEED_AVG to PlatformSpeedRecord.SPEED_AVG,
+ SpeedRecord.SPEED_MAX to PlatformSpeedRecord.SPEED_MAX,
+ SpeedRecord.SPEED_MIN to PlatformSpeedRecord.SPEED_MIN
+ )
+ } else {
+ emptyArray()
+ }.toMap()
+
internal val VOLUME_AGGREGATION_METRIC_TYPE_MAP:
Map<AggregateMetric<Volume>, PlatformAggregateMetric<PlatformVolume>> =
mapOf(
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
new file mode 100644
index 0000000..e8ea7d7
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/aggregate/HealthConnectClientAggregationExtensions.kt
@@ -0,0 +1,217 @@
+/*
+ * 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.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
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.IntervalRecord
+import androidx.health.connect.client.records.NutritionRecord
+import androidx.health.connect.client.records.Record
+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.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
+import kotlinx.coroutines.flow.fold
+
+// Max buffer to account for overlapping records that have startTime < timeRangeFilter.startTime
+val RECORD_START_TIME_BUFFER: Duration = Duration.ofDays(1)
+
+internal suspend fun HealthConnectClient.aggregateFallback(request: AggregateRequest):
+ AggregationResult {
+ return request.fallbackMetrics.fold(
+ AggregationResult(
+ longValues = mapOf(),
+ doubleValues = mapOf(),
+ dataOrigins = setOf()
+ )
+ ) { currentAggregateResult, metric ->
+ currentAggregateResult + aggregate(
+ metric,
+ request.timeRangeFilter,
+ request.dataOriginFilter
+ )
+ }
+}
+
+private suspend fun <T : Any> HealthConnectClient.aggregate(
+ metric: AggregateMetric<T>,
+ timeRangeFilter: TimeRangeFilter,
+ dataOriginFilter: Set<DataOrigin>
+): AggregationResult {
+ return when (metric) {
+ NutritionRecord.TRANS_FAT_TOTAL -> aggregateNutritionTransFatTotal(
+ timeRangeFilter,
+ dataOriginFilter
+ )
+
+ BloodPressureRecord.DIASTOLIC_AVG -> TODO(reason = "b/326414908")
+ BloodPressureRecord.DIASTOLIC_MAX -> TODO(reason = "b/326414908")
+ BloodPressureRecord.DIASTOLIC_MIN -> TODO(reason = "b/326414908")
+ BloodPressureRecord.SYSTOLIC_AVG -> TODO(reason = "b/326414908")
+ BloodPressureRecord.SYSTOLIC_MAX -> TODO(reason = "b/326414908")
+ BloodPressureRecord.SYSTOLIC_MIN -> TODO(reason = "b/326414908")
+ CyclingPedalingCadenceRecord.RPM_AVG -> TODO(reason = "b/326414908")
+ CyclingPedalingCadenceRecord.RPM_MAX -> TODO(reason = "b/326414908")
+ CyclingPedalingCadenceRecord.RPM_MIN -> TODO(reason = "b/326414908")
+ SpeedRecord.SPEED_AVG -> TODO(reason = "b/326414908")
+ SpeedRecord.SPEED_MAX -> TODO(reason = "b/326414908")
+ SpeedRecord.SPEED_MIN -> TODO(reason = "b/326414908")
+ StepsCadenceRecord.RATE_AVG -> TODO(reason = "b/326414908")
+ StepsCadenceRecord.RATE_MAX -> TODO(reason = "b/326414908")
+ StepsCadenceRecord.RATE_MIN -> TODO(reason = "b/326414908")
+ else -> error("Invalid fallback aggregation type ${metric.metricKey}")
+ }
+}
+
+@VisibleForTesting
+internal suspend fun HealthConnectClient.aggregateNutritionTransFatTotal(
+ timeRangeFilter: TimeRangeFilter,
+ dataOriginFilter: Set<DataOrigin>
+): AggregationResult {
+ val readRecordsFlow = readRecordsFlow(
+ NutritionRecord::class,
+ timeRangeFilter.withBufferedStart(),
+ dataOriginFilter
+ )
+
+ val aggregatedData = readRecordsFlow
+ .fold(AggregatedData(0.0)) { currentAggregatedData, records ->
+ val filteredRecords = records.filter {
+ it.overlaps(timeRangeFilter) && it.transFat != null &&
+ sliceFactor(it, timeRangeFilter) > 0
+ }
+
+ filteredRecords.forEach {
+ currentAggregatedData.value +=
+ it.transFat!!.inGrams * sliceFactor(it, timeRangeFilter)
+ }
+
+ filteredRecords.mapTo(currentAggregatedData.dataOrigins) { it.metadata.dataOrigin }
+ currentAggregatedData
+ }
+
+ if (aggregatedData.dataOrigins.isEmpty()) {
+ return emptyAggregationResult()
+ }
+
+ return AggregationResult(
+ longValues = mapOf(),
+ doubleValues = mapOf(NutritionRecord.TRANS_FAT_TOTAL.metricKey to aggregatedData.value),
+ dataOrigins = aggregatedData.dataOrigins
+ )
+}
+
+/** Reads all existing records that satisfy [timeRangeFilter] and [dataOriginFilter]. */
+@VisibleForTesting
+suspend fun <T : Record> HealthConnectClient.readRecordsFlow(
+ recordType: KClass<T>,
+ timeRangeFilter: TimeRangeFilter,
+ dataOriginFilter: Set<DataOrigin>
+): Flow<List<T>> {
+ return flow {
+ var pageToken: String? = null
+ do {
+ val response = readRecords(
+ ReadRecordsRequest(
+ recordType = recordType,
+ timeRangeFilter = timeRangeFilter,
+ dataOriginFilter = dataOriginFilter,
+ pageToken = pageToken
+ )
+ )
+ emit(response.records)
+ pageToken = response.pageToken
+ } while (pageToken != null)
+ }
+}
+
+private fun IntervalRecord.overlaps(timeRangeFilter: TimeRangeFilter): Boolean {
+ val startTimeOverlaps: Boolean
+ val endTimeOverlaps: Boolean
+ if (timeRangeFilter.useLocalTime()) {
+ startTimeOverlaps = timeRangeFilter.localEndTime == null ||
+ startTime.isBefore(
+ timeRangeFilter.localEndTime.toInstantWithDefaultZoneFallback(startZoneOffset)
+ )
+ endTimeOverlaps = timeRangeFilter.localStartTime == null ||
+ endTime.isAfter(
+ timeRangeFilter.localStartTime.toInstantWithDefaultZoneFallback(endZoneOffset)
+ )
+ } else {
+ startTimeOverlaps = timeRangeFilter.endTime == null ||
+ startTime.isBefore(timeRangeFilter.endTime)
+ endTimeOverlaps = timeRangeFilter.startTime == null ||
+ endTime.isAfter(timeRangeFilter.startTime)
+ }
+ return startTimeOverlaps && endTimeOverlaps
+}
+
+private fun TimeRangeFilter.withBufferedStart(): TimeRangeFilter {
+ return TimeRangeFilter(
+ startTime = startTime?.minus(RECORD_START_TIME_BUFFER),
+ endTime = endTime,
+ localStartTime = localStartTime?.minus(RECORD_START_TIME_BUFFER),
+ localEndTime = localEndTime
+ )
+}
+
+private 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)
+}
+
+private fun emptyAggregationResult() =
+ AggregationResult(longValues = mapOf(), doubleValues = mapOf(), dataOrigins = setOf())
+
+private data class AggregatedData<T>(
+ var value: T,
+ var dataOrigins: MutableSet<DataOrigin> = mutableSetOf()
+)
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/RequestConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/request/RequestConverters.kt
similarity index 78%
rename from health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/RequestConverters.kt
rename to health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/request/RequestConverters.kt
index 34699be..ac3fd89 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/RequestConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/request/RequestConverters.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 The Android Open Source Project
+ * 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.
@@ -17,7 +17,7 @@
@file:RestrictTo(RestrictTo.Scope.LIBRARY)
@file:RequiresApi(api = 34)
-package androidx.health.connect.client.impl.platform.records
+package androidx.health.connect.client.impl.platform.request
import android.health.connect.AggregateRecordsRequest
import android.health.connect.LocalTimeRangeFilter
@@ -30,6 +30,20 @@
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.impl.platform.aggregate.DOUBLE_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.DURATION_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.ENERGY_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.GRAMS_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.KILOGRAMS_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.LENGTH_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.LONG_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.POWER_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.PRESSURE_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.VELOCITY_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.VOLUME_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.platformMetrics
+import androidx.health.connect.client.impl.platform.records.toPlatformDataOrigin
+import androidx.health.connect.client.impl.platform.records.toPlatformRecordClass
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.request.AggregateGroupByDurationRequest
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
@@ -101,7 +115,7 @@
return AggregateRecordsRequest.Builder<Any>(timeRangeFilter.toPlatformTimeRangeFilter())
.apply {
dataOriginFilter.forEach { addDataOriginsFilter(it.toPlatformDataOrigin()) }
- metrics.forEach { addAggregationType(it.toAggregationType()) }
+ platformMetrics.forEach { addAggregationType(it.toAggregationType()) }
}
.build()
}
@@ -132,11 +146,13 @@
return DOUBLE_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: DURATION_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: ENERGY_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
+ ?: GRAMS_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: LENGTH_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: LONG_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
- ?: GRAMS_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: KILOGRAMS_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: POWER_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
+ ?: PRESSURE_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
+ ?: VELOCITY_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: VOLUME_AGGREGATION_METRIC_TYPE_MAP[this] as AggregationType<Any>?
?: throw IllegalArgumentException("Unsupported aggregation type $metricKey")
}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/ResponseConverters.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/response/ResponseConverters.kt
similarity index 70%
rename from health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/ResponseConverters.kt
rename to health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/response/ResponseConverters.kt
index 30e2279..a6ff703 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/records/ResponseConverters.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/platform/response/ResponseConverters.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 The Android Open Source Project
+ * 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.
@@ -17,7 +17,7 @@
@file:RestrictTo(RestrictTo.Scope.LIBRARY)
@file:RequiresApi(api = 34)
-package androidx.health.connect.client.impl.platform.records
+package androidx.health.connect.client.impl.platform.response
import android.health.connect.AggregateRecordsGroupedByDurationResponse
import android.health.connect.AggregateRecordsGroupedByPeriodResponse
@@ -32,6 +32,25 @@
import androidx.health.connect.client.aggregate.AggregationResult
import androidx.health.connect.client.aggregate.AggregationResultGroupedByDuration
import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
+import androidx.health.connect.client.impl.platform.aggregate.DOUBLE_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.DURATION_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.ENERGY_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.GRAMS_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.KILOGRAMS_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.LENGTH_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.LONG_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.POWER_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.PRESSURE_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.VELOCITY_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.aggregate.VOLUME_AGGREGATION_METRIC_TYPE_MAP
+import androidx.health.connect.client.impl.platform.records.PlatformDataOrigin
+import androidx.health.connect.client.impl.platform.records.PlatformLength
+import androidx.health.connect.client.impl.platform.records.PlatformMass
+import androidx.health.connect.client.impl.platform.records.PlatformPower
+import androidx.health.connect.client.impl.platform.records.PlatformPressure
+import androidx.health.connect.client.impl.platform.records.PlatformVelocity
+import androidx.health.connect.client.impl.platform.records.toSdkDataOrigin
+import androidx.health.connect.client.impl.platform.request.toAggregationType
import androidx.health.connect.client.units.Energy
import androidx.health.connect.client.units.Mass
import java.time.LocalDateTime
@@ -99,7 +118,7 @@
metricValueMap.forEach { (key, value) ->
if (
key in DURATION_AGGREGATION_METRIC_TYPE_MAP ||
- key in LONG_AGGREGATION_METRIC_TYPE_MAP
+ key in LONG_AGGREGATION_METRIC_TYPE_MAP
) {
this[key.metricKey] = value as Long
}
@@ -117,22 +136,36 @@
in DOUBLE_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] = value as Double
}
+
in ENERGY_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] =
Energy.calories((value as PlatformEnergy).inCalories).inKilocalories
}
- in LENGTH_AGGREGATION_METRIC_TYPE_MAP -> {
- this[key.metricKey] = (value as PlatformLength).inMeters
- }
+
in GRAMS_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] = (value as PlatformMass).inGrams
}
+
+ in LENGTH_AGGREGATION_METRIC_TYPE_MAP -> {
+ this[key.metricKey] = (value as PlatformLength).inMeters
+ }
+
in KILOGRAMS_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] = Mass.grams((value as PlatformMass).inGrams).inKilograms
}
+
+ in PRESSURE_AGGREGATION_METRIC_TYPE_MAP -> {
+ this[key.metricKey] = (value as PlatformPressure).inMillimetersOfMercury
+ }
+
in POWER_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] = (value as PlatformPower).inWatts
}
+
+ in VELOCITY_AGGREGATION_METRIC_TYPE_MAP -> {
+ this[key.metricKey] = (value as PlatformVelocity).inMetersPerSecond
+ }
+
in VOLUME_AGGREGATION_METRIC_TYPE_MAP -> {
this[key.metricKey] = (value as PlatformVolume).inLiters
}
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/platform/TimeExtensionsTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/platform/TimeExtensionsTest.kt
new file mode 100644
index 0000000..a6cdd65
--- /dev/null
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/platform/TimeExtensionsTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.health.connect.client.impl.platform
+
+import androidx.health.connect.client.records.NutritionRecord
+import androidx.health.connect.client.time.TimeRangeFilter
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import kotlin.test.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TimeExtensionsTest {
+
+ @Test
+ fun div() {
+ val dividend = Duration.ofHours(1)
+ val divisor = Duration.ofHours(4)
+ assertThat(dividend / divisor).isEqualTo(0.25)
+ }
+
+ @Test
+ fun dibByZero_returnsZero() {
+ val dividend = Duration.ofHours(1)
+ val divisor = Duration.ofSeconds(0)
+ assertThat(dividend / divisor).isEqualTo(0.0)
+ }
+
+ @Test
+ fun minus() {
+ val a = Instant.now()
+ val b = a.plusSeconds(5)
+ assertThat(b - a).isEqualTo(Duration.ofSeconds(5))
+ }
+
+ @Test
+ fun useLocalTime() {
+ assertThat(TimeRangeFilter.none().useLocalTime()).isFalse()
+ assertThat(
+ TimeRangeFilter.between(Instant.now(), Instant.now().plusSeconds(2)).useLocalTime()
+ ).isFalse()
+ assertThat(TimeRangeFilter.after(Instant.now()).useLocalTime()).isFalse()
+ assertThat(TimeRangeFilter.before(Instant.now()).useLocalTime()).isFalse()
+
+ assertThat(
+ TimeRangeFilter.between(LocalDateTime.now(), LocalDateTime.now().plusSeconds(2))
+ .useLocalTime()
+ ).isTrue()
+ assertThat(TimeRangeFilter.after(LocalDateTime.now()).useLocalTime()).isTrue()
+ assertThat(TimeRangeFilter.before(LocalDateTime.now()).useLocalTime()).isTrue()
+ }
+
+ @Test
+ fun toInstantWithDefaultZoneFallback() {
+ val instant = Instant.now()
+ val localDateTime = LocalDateTime.ofInstant(instant, ZoneOffset.UTC)
+
+ assertThat(localDateTime.toInstantWithDefaultZoneFallback(ZoneOffset.UTC))
+ .isEqualTo(instant)
+ assertThat(localDateTime.toInstantWithDefaultZoneFallback(ZoneOffset.ofHours(2)))
+ .isEqualTo(instant - Duration.ofHours(2))
+ }
+
+ @Test
+ fun intervalRecord_duration() {
+ val startTime = Instant.now()
+ val nutritionRecord = NutritionRecord(
+ startTime = startTime,
+ endTime = startTime.plusSeconds(10),
+ startZoneOffset = null,
+ endZoneOffset = null
+ )
+ assertThat(nutritionRecord.duration).isEqualTo(Duration.ofSeconds(10))
+ }
+}