Merge "Experimental Deduplication read API" into androidx-main
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/ExperimentalDeduplicationApi.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/ExperimentalDeduplicationApi.kt
new file mode 100644
index 0000000..6dbb08b
--- /dev/null
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/ExperimentalDeduplicationApi.kt
@@ -0,0 +1,26 @@
+/*
+ * 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
+
+import androidx.annotation.RestrictTo
+
+@RequiresOptIn(
+    message = "This is an experimental HC Dedupe-read API and is likely to change in the future."
+)
+@Retention(AnnotationRetention.BINARY)
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public annotation class ExperimentalDeduplicationApi
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientImpl.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientImpl.kt
index 75be7bf..5127a59 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientImpl.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/impl/HealthConnectClientImpl.kt
@@ -20,6 +20,7 @@
 import android.os.RemoteException
 import android.os.TransactionTooLargeException
 import androidx.annotation.VisibleForTesting
+import androidx.health.connect.client.ExperimentalDeduplicationApi
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.HealthConnectClient.Companion.HEALTH_CONNECT_CLIENT_TAG
 import androidx.health.connect.client.HealthConnectFeatures
@@ -50,6 +51,7 @@
 import androidx.health.connect.client.request.AggregateRequest
 import androidx.health.connect.client.request.ChangesTokenRequest
 import androidx.health.connect.client.request.ReadRecordsRequest
+import androidx.health.connect.client.request.ReadRecordsRequest.Companion.DEDUPLICATION_STRATEGY_DISABLED
 import androidx.health.connect.client.response.ChangesResponse
 import androidx.health.connect.client.response.InsertRecordsResponse
 import androidx.health.connect.client.response.ReadRecordResponse
@@ -214,9 +216,13 @@
         return toChangesResponse(proto)
     }
 
+    @OptIn(ExperimentalDeduplicationApi::class)
     override suspend fun <T : Record> readRecords(
         request: ReadRecordsRequest<T>,
     ): ReadRecordsResponse<T> {
+        if (request.deduplicateStrategy != DEDUPLICATION_STRATEGY_DISABLED) {
+            TODO("Not yet implemented")
+        }
         val proto = wrapRemoteException {
             delegate.readDataRange(toReadDataRangeRequestProto(request)).await()
         }
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 bbe062e..13cbe90 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
@@ -31,6 +31,7 @@
 import androidx.annotation.RequiresApi
 import androidx.annotation.VisibleForTesting
 import androidx.core.os.asOutcomeReceiver
+import androidx.health.connect.client.ExperimentalDeduplicationApi
 import androidx.health.connect.client.HealthConnectClient
 import androidx.health.connect.client.HealthConnectFeatures
 import androidx.health.connect.client.PermissionController
@@ -60,6 +61,7 @@
 import androidx.health.connect.client.request.AggregateRequest
 import androidx.health.connect.client.request.ChangesTokenRequest
 import androidx.health.connect.client.request.ReadRecordsRequest
+import androidx.health.connect.client.request.ReadRecordsRequest.Companion.DEDUPLICATION_STRATEGY_DISABLED
 import androidx.health.connect.client.response.ChangesResponse
 import androidx.health.connect.client.response.InsertRecordsResponse
 import androidx.health.connect.client.response.ReadRecordResponse
@@ -190,10 +192,14 @@
         return ReadRecordResponse(response.records[0].toSdkRecord() as T)
     }
 
+    @OptIn(ExperimentalDeduplicationApi::class)
     @Suppress("UNCHECKED_CAST") // Safe to cast as the type should match
     override suspend fun <T : Record> readRecords(
         request: ReadRecordsRequest<T>
     ): ReadRecordsResponse<T> {
+        if (request.deduplicateStrategy != DEDUPLICATION_STRATEGY_DISABLED) {
+            TODO("Not yet implemented")
+        }
         val response = wrapPlatformException {
             suspendCancellableCoroutine { continuation ->
                 healthConnectManager.readRecords(
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/request/ReadRecordsRequest.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/request/ReadRecordsRequest.kt
index c52e70d..b1e84d8 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/request/ReadRecordsRequest.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/request/ReadRecordsRequest.kt
@@ -15,7 +15,9 @@
  */
 package androidx.health.connect.client.request
 
+import androidx.annotation.IntDef
 import androidx.annotation.RestrictTo
+import androidx.health.connect.client.ExperimentalDeduplicationApi
 import androidx.health.connect.client.records.Record
 import androidx.health.connect.client.records.metadata.DataOrigin
 import androidx.health.connect.client.time.TimeRangeFilter
@@ -47,9 +49,9 @@
  * filters.
  *
  * Returned collection will contain a
- * [androidx.health.data.client.response.ReadRecordsResponse.pageToken] if number of records exceeds
- * [pageSize]. Use this if you expect an unbound number of records within specified time ranges.
- * Stops at any time once desired amount of records are processed.
+ * [androidx.health.connect.client.response.ReadRecordsResponse.pageToken] if number of records
+ * exceeds [pageSize]. Use this if you expect an unbound number of records within specified time
+ * ranges. Stops at any time once desired amount of records are processed.
  *
  * @param T type of [Record], such as `Steps`.
  * @param recordType Which type of [Record] to read, such as `Steps::class`.
@@ -65,7 +67,10 @@
  * @see androidx.health.connect.client.response.ReadRecordsResponse
  * @see androidx.health.connect.client.HealthConnectClient.readRecords
  */
-public class ReadRecordsRequest<T : Record>(
+public class ReadRecordsRequest<T : Record>
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+@ExperimentalDeduplicationApi
+constructor(
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val recordType: KClass<T>,
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val timeRangeFilter: TimeRangeFilter,
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -73,7 +78,28 @@
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val ascendingOrder: Boolean = true,
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val pageSize: Int = 1000,
     @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val pageToken: String? = null,
+    @DeduplicationStrategy
+    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    val deduplicateStrategy: Int = DEDUPLICATION_STRATEGY_ENABLED_DEFAULT,
 ) {
+    @OptIn(ExperimentalDeduplicationApi::class)
+    constructor(
+        recordType: KClass<T>,
+        timeRangeFilter: TimeRangeFilter,
+        dataOriginFilter: Set<DataOrigin> = emptySet(),
+        ascendingOrder: Boolean = true,
+        pageSize: Int = 1000,
+        pageToken: String? = null
+    ) : this(
+        recordType = recordType,
+        timeRangeFilter = timeRangeFilter,
+        dataOriginFilter = dataOriginFilter,
+        ascendingOrder = ascendingOrder,
+        pageSize = pageSize,
+        pageToken = pageToken,
+        deduplicateStrategy = DEDUPLICATION_STRATEGY_DISABLED,
+    )
+
     init {
         require(pageSize > 0) { "pageSize must be positive." }
     }
@@ -90,6 +116,7 @@
         if (ascendingOrder != other.ascendingOrder) return false
         if (pageSize != other.pageSize) return false
         if (pageToken != other.pageToken) return false
+        if (deduplicateStrategy != other.deduplicateStrategy) return false
 
         return true
     }
@@ -101,6 +128,59 @@
         result = 31 * result + ascendingOrder.hashCode()
         result = 31 * result + pageSize
         result = 31 * result + (pageToken?.hashCode() ?: 0)
+        result = 31 * result + (deduplicateStrategy.hashCode())
         return result
     }
+
+    /**
+     * Available strategies used to handle duplicate records in the
+     * [androidx.health.connect.client.response.ReadRecordsResponse.records].
+     */
+    @Retention(AnnotationRetention.SOURCE)
+    @IntDef(
+        value =
+            [
+                DEDUPLICATION_STRATEGY_DISABLED,
+                DEDUPLICATION_STRATEGY_ENABLED_DEFAULT,
+                DEDUPLICATION_STRATEGY_ENABLED_PRIORITIZE_CALLING_APP,
+            ]
+    )
+    @OptIn(ExperimentalDeduplicationApi::class)
+    @RestrictTo(RestrictTo.Scope.LIBRARY)
+    annotation class DeduplicationStrategy
+
+    @ExperimentalDeduplicationApi
+    internal companion object {
+        /**
+         * No deduplication handled. Returns all raw data.
+         *
+         * This is the default option.
+         */
+        @ExperimentalDeduplicationApi
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        const val DEDUPLICATION_STRATEGY_DISABLED = 0
+
+        /**
+         * Uses the default deduplication strategy recommended by Health Connect. This may change
+         * over time, it's not guaranteed the strategy remains the same over different updates.
+         *
+         * <p>Currently this is {@code DEDUPLICATION_STRATEGY_ENABLED_DEDUPE_ALL}. To stick to a
+         * specified strategy over updates, set the desired strategy directly.
+         */
+        @ExperimentalDeduplicationApi
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        const val DEDUPLICATION_STRATEGY_ENABLED_DEFAULT = 1
+
+        /**
+         * Strips all duplicate records in the database and returns a fully deduplicated list
+         * available via {@link ReadRecordsResponse#getRecords}. If duplications are detected, the
+         * record belongs to the calling app will be the winner.
+         *
+         * <p>Only records of session types like {@link ExerciseSessionRecord} and {@link
+         * SleepSessionRecord} are affected. It's no-op for other record types.
+         */
+        @ExperimentalDeduplicationApi
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        const val DEDUPLICATION_STRATEGY_ENABLED_PRIORITIZE_CALLING_APP = 2
+    }
 }
diff --git a/health/connect/connect-testing/src/main/java/androidx/health/connect/client/testing/FakeHealthConnectClient.kt b/health/connect/connect-testing/src/main/java/androidx/health/connect/client/testing/FakeHealthConnectClient.kt
index 5c7fab4..614d56b 100644
--- a/health/connect/connect-testing/src/main/java/androidx/health/connect/client/testing/FakeHealthConnectClient.kt
+++ b/health/connect/connect-testing/src/main/java/androidx/health/connect/client/testing/FakeHealthConnectClient.kt
@@ -255,6 +255,9 @@
     public override suspend fun <T : Record> readRecords(
         request: ReadRecordsRequest<T>
     ): ReadRecordsResponse<T> {
+        if (request.deduplicateStrategy != DEDUPLICATION_STRATEGY_DISABLED) {
+            TODO("Not yet implemented")
+        }
         // Stubs
         overrides.readRecords?.throwOrContinue(null)
 
@@ -439,6 +442,14 @@
     public companion object {
         /** Default package name used in [FakeHealthConnectClient]. */
         public const val DEFAULT_PACKAGE_NAME: String = "androidx.health.connect.test"
+
+        /**
+         * Default dedupe strategy constant. This is a workaround to bypass b/358308051
+         *
+         * It should be removed (and reference the one in [ReadRecordsRequest.Companion]) when the
+         * dedupe API is ready to publish.
+         */
+        private const val DEDUPLICATION_STRATEGY_DISABLED = 0
     }
 }