HealthConnectClient.kt

/*
 * Copyright (C) 2022 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 android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.RemoteException
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.RestrictTo
import androidx.health.connect.client.aggregate.AggregateMetric
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.HealthConnectClientImpl
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.AggregateGroupByDurationRequest
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
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.response.ChangesResponse
import androidx.health.connect.client.response.InsertRecordsResponse
import androidx.health.connect.client.response.ReadRecordResponse
import androidx.health.connect.client.response.ReadRecordsResponse
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.platform.client.HealthDataService
import java.io.IOException
import kotlin.reflect.KClass

/** Interface to access health and fitness records. */
interface HealthConnectClient {

    /** Access operations related to permissions. */
    val permissionController: PermissionController

    /**
     * Inserts one or more [Record] and returns newly assigned
     * [androidx.health.connect.client.records.metadata.Metadata.id] generated. Insertion of
     * multiple [records] is executed in a transaction - if one fails, none is inserted.
     *
     * For example, to insert basic data like step counts:
     * @sample androidx.health.connect.client.samples.InsertSteps
     *
     * To insert more complex data like nutrition for a user who’s eaten a banana:
     * @sample androidx.health.connect.client.samples.InsertNutrition
     *
     * To insert some heart rate data:
     * @sample androidx.health.connect.client.samples.InsertHeartRateSeries
     *
     * [androidx.health.connect.client.records.metadata.Metadata.clientRecordId] can be used to
     * deduplicate data with a client provided unique identifier. When a subsequent [insertRecords]
     * is called with the same
     * [androidx.health.connect.client.records.metadata.Metadata.clientRecordId], whichever [Record]
     * with the higher
     * [androidx.health.connect.client.records.metadata.Metadata.clientRecordVersion] takes
     * precedence.
     *
     * @param records List of records to insert
     * @return List of unique identifiers in the order of inserted records.
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun insertRecords(records: List<Record>): InsertRecordsResponse

    /**
     * Updates one or more [Record] of given UIDs to newly specified values. Update of multiple
     * [records] is executed in a transaction - if one fails, none is inserted.
     *
     * @param records List of records to update
     * @throws RemoteException For any IPC transportation failures. Update with invalid identifiers
     * will result in IPC failure.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun updateRecords(records: List<Record>)

    /**
     * Deletes one or more [Record] by their identifiers. Deletion of multiple [Record] is executed
     * in single transaction - if one fails, none is deleted.
     *
     * Example usage to delete written steps data by its unique identifier:
     * @sample androidx.health.connect.client.samples.DeleteByUniqueIdentifier
     *
     * @param recordType Which type of [Record] to delete, such as `Steps::class`
     * @param recordIdsList List of
     * [androidx.health.connect.client.records.metadata.Metadata.id] of [Record] to delete
     * @param clientRecordIdsList List of client record IDs of [Record] to delete
     * @throws RemoteException For any IPC transportation failures. Deleting by invalid identifiers
     * such as a non-existing identifier or deleting the same record multiple times will result in
     * IPC failure.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun deleteRecords(
        recordType: KClass<out Record>,
        recordIdsList: List<String>,
        clientRecordIdsList: List<String>,
    )

    /**
     * Deletes any [Record] of the given [recordType] in the given [timeRangeFilter] (automatically
     * filtered to [Record] belonging to the calling application). Deletion of multiple [Record] is
     * executed in a transaction - if one fails, none is deleted.
     *
     * Example usage to delete written steps data in a time range:
     * @sample androidx.health.connect.client.samples.DeleteByTimeRange
     *
     * @param recordType Which type of [Record] to delete, such as `Steps::class`
     * @param timeRangeFilter The [TimeRangeFilter] to delete from
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun deleteRecords(recordType: KClass<out Record>, timeRangeFilter: TimeRangeFilter)

    /**
     * Reads one [Record] point with its [recordType] and [recordId].
     *
     * @param recordType Which type of [Record] to read, such as `Steps::class`
     * @param recordId [androidx.health.connect.client.records.metadata.Metadata.id] of
     * [Record] to read
     * @return The [Record] data point.
     * @throws RemoteException For any IPC transportation failures. Update with invalid identifiers
     * will result in IPC failure.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun <T : Record> readRecord(
        recordType: KClass<T>,
        recordId: String
    ): ReadRecordResponse<T>

    /**
     * Retrieves a collection of [Record]s.
     *
     * Example code to read basic data like step counts:
     * @sample androidx.health.connect.client.samples.ReadStepsRange
     *
     * @param T the type of [Record]
     * @param request [ReadRecordsRequest] object specifying time range and other filters
     *
     * @return a response containing a collection of [Record]s.
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun <T : Record> readRecords(request: ReadRecordsRequest<T>): ReadRecordsResponse<T>

    /**
     * Reads [AggregateMetric]s according to requested read criteria: [Record]s from
     * [AggregateRequest.dataOriginFilter] and within [AggregateRequest.timeRangeFilter].
     *
     * Example code to aggregate cumulative data like distance:
     * @sample androidx.health.connect.client.samples.AggregateDistance
     *
     * Example code to retrieve statistical aggregates like maximum or minimum heart rate:
     * @sample androidx.health.connect.client.samples.AggregateHeartRate
     *
     * @param request [AggregateRequest] object specifying [AggregateMetric]s to aggregate and other
     * filters.
     *
     * @return the [AggregationResult] that contains aggregated values.
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun aggregate(request: AggregateRequest): AggregationResult

    /**
     * Reads [AggregateMetric]s according to requested read criteria specified in
     * [AggregateGroupByDurationRequest].
     *
     * This method is similar to [aggregate] but instead of returning one [AggregationResult] for
     * the entire query's time interval, it returns a list of [AggregationResultGroupedByDuration],
     * with each row keyed by start and end time. For example: steps for today bucketed by hours.
     *
     * An [AggregationResultGroupedByDuration] is returned only if there are [Record] to aggregate
     * within start and end time of the row.
     *
     * Example code to retrieve cumulative step count for each minute within provided time range:
     * @sample androidx.health.connect.client.samples.AggregateIntoMinutes
     *
     * @param request [AggregateGroupByDurationRequest] object specifying [AggregateMetric]s to
     * aggregate and other filters.
     *
     * @return a list of [AggregationResultGroupedByDuration]s, each contains aggregated values and
     * start/end time of the row. The list is sorted by time in ascending order.
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun aggregateGroupByDuration(
        request: AggregateGroupByDurationRequest,
    ): List<AggregationResultGroupedByDuration>

    /**
     * Reads [AggregateMetric]s according to requested read criteria specified in
     * [AggregateGroupByPeriodRequest].
     *
     * This method is similar to [aggregate] but instead of returning one [AggregationResult] for
     * the entire query's time interval, it returns a list of [AggregationResultGroupedByPeriod],
     * with each row keyed by start and end time. For example: steps for this month bucketed by day.
     *
     * An [AggregationResultGroupedByPeriod] is returned only if there are [Record] to aggregate
     * within start and end time of the row.
     *
     * Example code to retrieve cumulative step count for each month within provided time range:
     * @sample androidx.health.connect.client.samples.AggregateIntoMonths
     *
     * @param request [AggregateGroupByPeriodRequest] object specifying [AggregateMetric]s to
     * aggregate and other filters.
     *
     * @return a list of [AggregationResultGroupedByPeriod]s, each contains aggregated values and
     * start/end time of the row. The list is sorted by time in ascending order.
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     */
    suspend fun aggregateGroupByPeriod(
        request: AggregateGroupByPeriodRequest,
    ): List<AggregationResultGroupedByPeriod>

    /**
     * Retrieves a changes-token, representing a point in time in the underlying Android Health
     * Platform for a given [ChangesTokenRequest]. Changes-tokens are used in [getChanges] to
     * retrieve changes since that point in time.
     *
     * Changes-tokens represent a point in time after which the client is interested in knowing the
     * changes for a set of interested types of [Record] and optional [DataOrigin] filters.
     *
     * Changes-tokens are only valid for 30 days after they're generated. Calls to [getChanges] with
     * an expired changes-token will lead to [ChangesResponse.changesTokenExpired]
     *
     * @param request Includes interested types of record to observe changes and optional filters.
     * @return a changes-token
     *
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IllegalStateException If service is not available.
     *
     * @see getChanges
     */
    suspend fun getChangesToken(request: ChangesTokenRequest): String

    /**
     * Registers the provided [notificationIntentAction] and [recordTypes] for data notifications.
     *
     * Health Connect will automatically broadcast notification messages to the client application
     * with the action specified by [notificationIntentAction] argument. Messages are sent when the
     * data specified by [recordTypes] is updated. Messages may not be sent immediately, but in
     * batches.
     *
     * The client application must have a read permission granted for a [Record] in order to receive
     * notifications for it.
     *
     * The client application must have a [BroadcastReceiver][android.content.BroadcastReceiver]
     * registered, either in the `AndroidManifest.xml` file or at runtime. The registered
     * `BroadcastReceiver` must have an [IntentFilter][android.content.IntentFilter] specified with
     * the same action as in [notificationIntentAction] argument.
     * [DataNotification][androidx.health.connect.client.datanotification.DataNotification] can be
     * used to extract data from the received [Intent].
     *
     * @param notificationIntentAction an action to be used for broadcast messages.
     * @param recordTypes specifies [Record] types of interest.
     *
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     *
     * @see unregisterFromDataNotifications
     * @see androidx.health.connect.client.datanotification.DataNotification
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY) // Not yet ready for public
    suspend fun registerForDataNotifications(
        notificationIntentAction: String,
        recordTypes: Iterable<KClass<out Record>>,
    )

    /**
     * Unregisters the provided [notificationIntentAction] from data notifications.
     *
     * @param notificationIntentAction an action previously registered using
     * [registerForDataNotifications] method.
     *
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IOException For any disk I/O issues.
     * @throws IllegalStateException If service is not available.
     *
     * @see registerForDataNotifications
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY) // Not yet ready for public
    suspend fun unregisterFromDataNotifications(notificationIntentAction: String)

    /**
     * Retrieves changes in Android Health Platform, from a specific point in time represented by
     * provided [changesToken].
     *
     * The response returned may not provide all the changes due to IPC or memory limits, see
     * [ChangesResponse.hasMore]. Clients can make more api calls to fetch more changes from the
     * Android Health Platform with updated [ChangesResponse.nextChangesToken].
     *
     * Provided [changesToken] may have expired if clients have not synced for extended period of
     * time (such as a month). In this case [ChangesResponse.changesTokenExpired] will be set, and
     * clients should generate a new changes-token via [getChangesToken].
     *
     * ```
     * val response = client.getChanges(changesToken)
     * if (response.changesTokenExpired) {
     *   // Consider re-sync and fetch new changes token.
     * } else {
     *   // Process new insertion/deletions, either update local storage or upload to backends.
     * }
     * ```
     *
     * @param changesToken A Changes-Token that represents a specific point in time in Android
     * Health Platform.
     * @return a [ChangesResponse] with changes since provided [changesToken].
     *
     * @throws RemoteException For any IPC transportation failures.
     * @throws SecurityException For requests with unpermitted access.
     * @throws IllegalStateException If service is not available.
     *
     * @see getChangesToken
     */
    suspend fun getChanges(changesToken: String): ChangesResponse

    companion object {
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        internal const val DEFAULT_PROVIDER_PACKAGE_NAME = "com.google.android.apps.healthdata"

        /**
         * Determines whether an implementation of [HealthConnectClient] is available on this device
         * at the moment.
         *
         * @param packageNames optional package provider to choose implementation from
         * @return whether the api is available
         */
        @JvmOverloads
        @JvmStatic
        public fun isAvailable(
            context: Context,
            packageNames: List<String> = listOf(DEFAULT_PROVIDER_PACKAGE_NAME),
        ): Boolean {
            if (!isSdkVersionSufficient()) {
                return false
            }
            return packageNames.any { isPackageInstalled(context.packageManager, it) }
        }

        /**
         * Retrieves an IPC-backed [HealthConnectClient] instance binding to an available
         * implementation.
         *
         * @param packageNames optional package provider to choose implementation from
         * @return instance of [HealthConnectClient] ready for issuing requests
         * @throws UnsupportedOperationException if service not available due to SDK version too low
         * @throws IllegalStateException if service not available due to not installed
         *
         * @see isAvailable
         */
        @JvmOverloads
        @JvmStatic
        public fun getOrCreate(
            context: Context,
            packageNames: List<String> = listOf(DEFAULT_PROVIDER_PACKAGE_NAME),
        ): HealthConnectClient {
            if (!isSdkVersionSufficient()) {
                throw UnsupportedOperationException("SDK version too low")
            }
            if (!isAvailable(context, packageNames)) {
                throw IllegalStateException("Service not available")
            }
            val enabledPackage =
                packageNames.first { isPackageInstalled(context.packageManager, it) }
            return HealthConnectClientImpl(
                enabledPackage,
                HealthDataService.getClient(context, enabledPackage)
            )
        }

        @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.P)
        internal fun isSdkVersionSufficient() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P

        internal fun isPackageInstalled(
            packageManager: PackageManager,
            packageName: String,
        ): Boolean {
            val isPackageInstalledAndEnabled =
                try {
                    @Suppress("Deprecation") // getApplicationInfo deprecated in T
                    packageManager.getApplicationInfo(packageName, /* flags= */ 0).enabled
                } catch (e: PackageManager.NameNotFoundException) {
                    false
                }
            return isPackageInstalledAndEnabled && hasBindableService(packageManager, packageName)
        }

        internal fun hasBindableService(
            packageManager: PackageManager,
            packageName: String
        ): Boolean {
            val bindIntent = Intent()
            bindIntent.setPackage(packageName)
            bindIntent.setAction(HealthDataService.ANDROID_HEALTH_PLATFORM_SERVICE_BIND_ACTION)
            @Suppress("Deprecation") // deprecated in T
            return packageManager.queryIntentServices(bindIntent, 0).isNotEmpty()
        }

        /**
         * Tag used in SDK debug logs.
         * @suppress
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY)
        internal const val HEALTH_CONNECT_CLIENT_TAG = "HealthConnectClient"
    }
}