From cd313a76c08822ce06fe0c961ee326dbb59cc992 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Wed, 20 Mar 2024 18:23:44 +0000 Subject: [PATCH] VIT-6034: Gracefully handle missing permission when generating Health Connect changes token (#102) --- .../SyncNotificationBuilder.kt | 3 +- .../io/tryvital/vitalhealthconnect/Utils.kt | 1 + .../vitalhealthconnect/model/VitalResource.kt | 1 + .../workers/ResourceSyncWorker.kt | 55 +++++++++++++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/SyncNotificationBuilder.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/SyncNotificationBuilder.kt index a317f87d..1a24df43 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/SyncNotificationBuilder.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/SyncNotificationBuilder.kt @@ -53,7 +53,8 @@ class DefaultSyncNotificationBuilder( } private fun createChannel(context: Context, content: DefaultSyncNotificationContent): String { - val importance = NotificationManager.IMPORTANCE_MIN + // We cannot use IMPORTANT_MIN for FGS, or else Android will coerce it to IMPORTANT_HIGH + val importance = NotificationManager.IMPORTANCE_LOW val mChannel = NotificationChannel("VitalHealthConnectSync", content.channelName, importance) mChannel.description = content.channelDescription val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt index de2a6c3b..af924734 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt @@ -13,6 +13,7 @@ object UnSecurePrefKeys { internal const val nextAlarmAtKey = "nextAlarmAt" internal const val lastAutoSyncedAtKey = "lastAutoSyncedAt" internal const val lastSeenWorkIdKey = "lastSeenWorkId" + internal const val typesMonitoredByChangesTokenKey = "typesMonitoredByChangesToken" internal fun readResourceGrant(resource: VitalResource) = "resource.read.$resource" internal fun writeResourceGrant(resource: WritableVitalResource) = "resource.write.$resource" } diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/VitalResource.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/VitalResource.kt index 6a52a46d..f753d073 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/VitalResource.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/VitalResource.kt @@ -149,6 +149,7 @@ fun VitalResource.recordTypeChangesToTriggerSync(): List> = w VitalResource.Water -> listOf(HydrationRecord::class) VitalResource.Activity -> listOf( ActiveCaloriesBurnedRecord::class, + TotalCaloriesBurnedRecord::class, BasalMetabolicRateRecord::class, StepsRecord::class, DistanceRecord::class, diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/workers/ResourceSyncWorker.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/workers/ResourceSyncWorker.kt index 993dc19c..1612c318 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/workers/ResourceSyncWorker.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/workers/ResourceSyncWorker.kt @@ -3,6 +3,8 @@ package io.tryvital.vitalhealthconnect.workers import android.content.Context import android.content.SharedPreferences import android.os.Build +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.Record import androidx.health.connect.client.request.ChangesTokenRequest import androidx.health.connect.client.response.ChangesResponse import androidx.work.CoroutineWorker @@ -16,6 +18,7 @@ import io.tryvital.client.VitalClient import io.tryvital.client.services.data.DataStage import io.tryvital.client.utils.VitalLogger import io.tryvital.vitalhealthconnect.HealthConnectClientProvider +import io.tryvital.vitalhealthconnect.UnSecurePrefKeys import io.tryvital.vitalhealthconnect.ext.toDate import io.tryvital.vitalhealthconnect.model.VitalResource import io.tryvital.vitalhealthconnect.model.processedresource.ProcessedResourceData @@ -33,6 +36,7 @@ import java.time.ZonedDateTime import java.time.temporal.ChronoUnit import java.util.Date import java.util.TimeZone +import kotlin.reflect.KClass const val VITAL_SYNC_NOTIFICATION_ID = 123 @@ -174,6 +178,21 @@ internal class ResourceSyncWorker(appContext: Context, workerParams: WorkerParam val userId = vitalClient.checkUserId() val client = healthConnectClientProvider.getHealthConnectClient(applicationContext) + val recordTypesToMonitor = recordTypesToMonitor().toSimpleNameSet() + val monitoringTypes = monitoringRecordTypes() + + // The types being monitored by the current `changesToken` no longer match the set + // we want to monitor, probably due to permission changes. + // Treat this as if the changesToken has expired. + if (recordTypesToMonitor != monitoringTypes) { + return genericBackfill( + stage = DataStage.Daily, + start = state.lastSync.toInstant(), + end = Instant.now(), + timeZone = timeZone, + ) + } + var token = state.changesToken var changes: ChangesResponse @@ -218,11 +237,16 @@ internal class ResourceSyncWorker(appContext: Context, workerParams: WorkerParam private suspend fun genericBackfill(stage: DataStage, start: Instant, end: Instant, timeZone: TimeZone) { val userId = vitalClient.checkUserId() val client = healthConnectClientProvider.getHealthConnectClient(applicationContext) - var token = client.getChangesToken( - ChangesTokenRequest( - recordTypes = input.resource.recordTypeChangesToTriggerSync().toSet(), + + val recordTypesToMonitor = recordTypesToMonitor() + var token = client.getChangesToken(ChangesTokenRequest(recordTypes = recordTypesToMonitor)) + + sharedPreferences.edit() + .putStringSet( + UnSecurePrefKeys.typesMonitoredByChangesTokenKey, + recordTypesToMonitor.toSimpleNameSet() ) - ) + .apply() val (stageStart, stageEnd) = when (stage) { // Historical stage must pass the same start ..< end throughout all the chunks. @@ -274,6 +298,25 @@ internal class ResourceSyncWorker(appContext: Context, workerParams: WorkerParam setIncremental(token = token) } + private fun monitoringRecordTypes(): Set { + return sharedPreferences.getStringSet(UnSecurePrefKeys.typesMonitoredByChangesTokenKey, null) ?: setOf() + } + + /** + * Health Connect rejects the request if we include [Record] types we do not have permission + * for. So we need to proactively filter out [Record] types based on what read permissions we + * have at the moment. + */ + private suspend fun recordTypesToMonitor(): Set> { + val client = healthConnectClientProvider.getHealthConnectClient(applicationContext) + val grantedPermissions = client.permissionController.getGrantedPermissions() + + return input.resource.recordTypeChangesToTriggerSync() + .filterTo(mutableSetOf()) { recordType -> + HealthPermission.getReadPermission(recordType) in grantedPermissions + } + } + private fun setIncremental(token: String) { val newState = ResourceSyncState.Incremental(token, lastSync = Date()) @@ -298,3 +341,7 @@ internal inline fun SharedPreferences.Editor.putJson(key: Strin } internal val VitalResource.syncStateKey get() = "sync-state.${this.name}" + +// All Record types are public JVM types, so they must have a simple name. +private fun Set>.toSimpleNameSet(): Set + = mapTo(mutableSetOf()) { it.simpleName!! } \ No newline at end of file