HealthConnectClientUpsideDownImpl.kt

/*
 * Copyright 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.impl

import android.content.Context
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
import android.content.pm.PackageManager.GET_PERMISSIONS
import android.content.pm.PackageManager.PackageInfoFlags
import android.health.connect.HealthConnectException
import android.health.connect.HealthConnectManager
import android.health.connect.ReadRecordsRequestUsingIds
import android.health.connect.RecordIdFilter
import android.health.connect.changelog.ChangeLogsRequest
import android.os.RemoteException
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.core.os.asOutcomeReceiver
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
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.changes.DeletionChange
import androidx.health.connect.client.changes.UpsertionChange
import androidx.health.connect.client.impl.platform.records.toPlatformRecord
import androidx.health.connect.client.impl.platform.records.toPlatformRecordClass
import androidx.health.connect.client.impl.platform.records.toPlatformRequest
import androidx.health.connect.client.impl.platform.records.toPlatformTimeRangeFilter
import androidx.health.connect.client.impl.platform.records.toSdkRecord
import androidx.health.connect.client.impl.platform.records.toSdkResponse
import androidx.health.connect.client.impl.platform.response.toKtResponse
import androidx.health.connect.client.impl.platform.toKtException
import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_PREFIX
import androidx.health.connect.client.records.Record
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 kotlin.reflect.KClass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * Implements the [HealthConnectClient] with APIs in UpsideDownCake.
 *
 * @suppress
 */
@RequiresApi(api = 34)
class HealthConnectClientUpsideDownImpl : HealthConnectClient, PermissionController {

    private val executor = Dispatchers.Default.asExecutor()

    private val context: Context
    private val healthConnectManager: HealthConnectManager
    private val revokePermissionsFunction: (Collection<String>) -> Unit

    constructor(context: Context) : this(context, context::revokeSelfPermissionsOnKill)

    @VisibleForTesting
    internal constructor(
        context: Context,
        revokePermissionsFunction: (Collection<String>) -> Unit
    ) {
        this.context = context
        this.healthConnectManager =
            context.getSystemService(Context.HEALTHCONNECT_SERVICE) as HealthConnectManager
        this.revokePermissionsFunction = revokePermissionsFunction
    }

    override val permissionController: PermissionController
        get() = this

    override suspend fun insertRecords(records: List<Record>): InsertRecordsResponse {
        val response = wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.insertRecords(
                    records.map { it.toPlatformRecord() },
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
        return response.toKtResponse()
    }

    override suspend fun updateRecords(records: List<Record>) {
        wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.updateRecords(
                    records.map { it.toPlatformRecord() },
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
    }

    override suspend fun deleteRecords(
        recordType: KClass<out Record>,
        recordIdsList: List<String>,
        clientRecordIdsList: List<String>
    ) {
        wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.deleteRecords(
                    buildList {
                        recordIdsList.forEach {
                            add(RecordIdFilter.fromId(recordType.toPlatformRecordClass(), it))
                        }
                        clientRecordIdsList.forEach {
                            add(
                                RecordIdFilter.fromClientRecordId(
                                    recordType.toPlatformRecordClass(),
                                    it
                                )
                            )
                        }
                    },
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
    }

    override suspend fun deleteRecords(
        recordType: KClass<out Record>,
        timeRangeFilter: TimeRangeFilter
    ) {
        wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.deleteRecords(
                    recordType.toPlatformRecordClass(),
                    timeRangeFilter.toPlatformTimeRangeFilter(),
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
    }

    @Suppress("UNCHECKED_CAST") // Safe to cast as the type should match
    override suspend fun <T : Record> readRecord(
        recordType: KClass<T>,
        recordId: String
    ): ReadRecordResponse<T> {
        val response = wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.readRecords(
                    ReadRecordsRequestUsingIds.Builder(recordType.toPlatformRecordClass())
                        .addId(recordId)
                        .build(),
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
        if (response.records.isEmpty()) {
            throw RemoteException("No records")
        }
        return ReadRecordResponse(response.records[0].toSdkRecord() as T)
    }

    @Suppress("UNCHECKED_CAST") // Safe to cast as the type should match
    override suspend fun <T : Record> readRecords(
        request: ReadRecordsRequest<T>
    ): ReadRecordsResponse<T> {
        val response = wrapPlatformException {
            suspendCancellableCoroutine { continuation ->
                healthConnectManager.readRecords(
                    request.toPlatformRequest(),
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
        }
        return ReadRecordsResponse(
            response.records.map { it.toSdkRecord() as T },
            pageToken = response.nextPageToken.takeUnless { it == -1L }?.toString()
        )
    }

    override suspend fun aggregate(request: AggregateRequest): AggregationResult {
        return wrapPlatformException {
                suspendCancellableCoroutine { continuation ->
                    healthConnectManager.aggregate(
                        request.toPlatformRequest(),
                        executor,
                        continuation.asOutcomeReceiver()
                    )
                }
            }
            .toSdkResponse(request.metrics)
    }

    override suspend fun aggregateGroupByDuration(
        request: AggregateGroupByDurationRequest
    ): List<AggregationResultGroupedByDuration> {
        return wrapPlatformException {
                suspendCancellableCoroutine { continuation ->
                    healthConnectManager.aggregateGroupByDuration(
                        request.toPlatformRequest(),
                        request.timeRangeSlicer,
                        executor,
                        continuation.asOutcomeReceiver()
                    )
                }
            }
            .map { it.toSdkResponse(request.metrics) }
    }

    override suspend fun aggregateGroupByPeriod(
        request: AggregateGroupByPeriodRequest
    ): List<AggregationResultGroupedByPeriod> {
        return wrapPlatformException {
                suspendCancellableCoroutine { continuation ->
                    healthConnectManager.aggregateGroupByPeriod(
                        request.toPlatformRequest(),
                        request.timeRangeSlicer,
                        executor,
                        continuation.asOutcomeReceiver()
                    )
                }
            }
            .map { it.toSdkResponse(request.metrics) }
    }

    override suspend fun getChangesToken(request: ChangesTokenRequest): String {
        return wrapPlatformException {
                suspendCancellableCoroutine { continuation ->
                    healthConnectManager.getChangeLogToken(
                        request.toPlatformRequest(),
                        executor,
                        continuation.asOutcomeReceiver()
                    )
                }
            }
            .token
    }

    override suspend fun getChanges(changesToken: String): ChangesResponse {
        try {
            val response = suspendCancellableCoroutine { continuation ->
                healthConnectManager.getChangeLogs(
                    ChangeLogsRequest.Builder(changesToken).build(),
                    executor,
                    continuation.asOutcomeReceiver()
                )
            }
            return ChangesResponse(
                buildList {
                    response.upsertedRecords.forEach { add(UpsertionChange(it.toSdkRecord())) }
                    response.deletedLogs.forEach { add(DeletionChange(it.deletedRecordId)) }
                },
                response.nextChangesToken,
                response.hasMorePages(),
                changesTokenExpired = false
            )
        } catch (e: HealthConnectException) {
            // Handle invalid token
            if (e.errorCode == HealthConnectException.ERROR_INVALID_ARGUMENT) {
                return ChangesResponse(
                    changes = listOf(),
                    nextChangesToken = "",
                    hasMore = false,
                    changesTokenExpired = true
                )
            }
            throw e.toKtException()
        }
    }

    override suspend fun getGrantedPermissions(): Set<String> {
        context.packageManager
            .getPackageInfo(context.packageName, PackageInfoFlags.of(GET_PERMISSIONS.toLong()))
            .let {
                return buildSet {
                    for (i in it.requestedPermissions.indices) {
                        if (
                            it.requestedPermissions[i].startsWith(PERMISSION_PREFIX) &&
                                it.requestedPermissionsFlags[i] and REQUESTED_PERMISSION_GRANTED > 0
                        ) {
                            add(it.requestedPermissions[i])
                        }
                    }
                }
            }
    }

    override suspend fun revokeAllPermissions() {
        val allHealthPermissions =
            context.packageManager
                .getPackageInfo(context.packageName, PackageInfoFlags.of(GET_PERMISSIONS.toLong()))
                .requestedPermissions
                .filter { it.startsWith(PERMISSION_PREFIX) }
        revokePermissionsFunction(allHealthPermissions)
    }

    private suspend fun <T> wrapPlatformException(function: suspend () -> T): T {
        return try {
            function()
        } catch (e: HealthConnectException) {
            throw e.toKtException()
        }
    }
}