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