diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt index a922f95f..b1ee1e46 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/Utils.kt @@ -17,9 +17,12 @@ object UnSecurePrefKeys { internal const val autoSyncThrottleKey = "autoSyncThrottle" internal const val backgroundSyncMinIntervalKey = "backgroundSyncMinInterval" + internal const val currentAskRequest = "currentAskRequest" + internal fun syncStateKey(resource: VitalResource) = "sync-state.${resource.name}" internal fun monitoringTypesKey(resource: VitalResource) = "monitoringTypes.${resource.name}" + internal fun requestCount(permission: String) = "requestCount.$permission" 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/VitalHealthConnectManager.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalHealthConnectManager.kt index 38fac882..75e7bd6b 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalHealthConnectManager.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalHealthConnectManager.kt @@ -180,9 +180,9 @@ class VitalHealthConnectManager private constructor( .toSet() } - internal suspend fun checkAndUpdatePermissions(): Set { + internal suspend fun checkAndUpdatePermissions(): Pair, Set> { if (isAvailable(context) != HealthConnectAvailability.Installed) { - return setOf() + return emptySet() to emptySet() } val lastKnownGrantedResources = resourcesWithReadPermission() @@ -205,7 +205,7 @@ class VitalHealthConnectManager private constructor( apply() } - return upToDateGrantedResources - lastKnownGrantedResources + return upToDateGrantedResources to upToDateGrantedResources - lastKnownGrantedResources } internal fun permissionsRequiredToWriteResources(resources: Set): Set { diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalPermissionRequestContract.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalPermissionRequestContract.kt index 28a925fc..c44fb1cd 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalPermissionRequestContract.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/VitalPermissionRequestContract.kt @@ -6,6 +6,7 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.health.connect.client.PermissionController import io.tryvital.client.utils.VitalLogger import io.tryvital.vitalhealthconnect.model.* +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -28,38 +29,63 @@ class VitalPermissionRequestContract( return SynchronousResult(CompletableDeferred(PermissionOutcome.HealthConnectUnavailable)) } - val grantedPermissions = contract.getSynchronousResult(context, permissionsToRequest())?.value + val permissions = permissionsToRequest() + val grantedPermissions = contract.getSynchronousResult(context, permissions)?.value return if (grantedPermissions != null) { - processGrantedPermissionsAsync(grantedPermissions).let(::SynchronousResult) + processGrantedPermissionsAsync(requested = permissions, granted = grantedPermissions) + .let(::SynchronousResult) } else { null } } - override fun createIntent(context: Context, input: Unit): Intent - = contract.createIntent(context, permissionsToRequest()) + override fun createIntent(context: Context, input: Unit): Intent { + val permissions = permissionsToRequest() + + // Health Connect keeps a counter per individual permission + // 14+: count both Cancel and Allow-but-Unselected + // 13: count only when user presses Cancel + val prefs = manager.sharedPreferences + prefs.edit().apply { + for (permission in permissions) { + val key = UnSecurePrefKeys.requestCount(permission) + putLong(key, prefs.getLong(key, 0) + 1) + } + putStringSet(UnSecurePrefKeys.currentAskRequest, permissions) + apply() + } + + return contract.createIntent(context, permissions) + } @Suppress("DeferredIsResult") override fun parseResult(resultCode: Int, intent: Intent?): Deferred { val grantedPermissions = contract.parseResult(resultCode, intent) if (intent == null) { - return CompletableDeferred( - PermissionOutcome.Failure(cause = IllegalStateException("Missing intent in parseResult.")) - ) + val outcome = when (resultCode) { + 0 -> PermissionOutcome.Cancelled + else -> PermissionOutcome.UnknownError(IllegalStateException("Missing intent in parseResult.")) + } + return CompletableDeferred(outcome) } - return processGrantedPermissionsAsync(grantedPermissions) + val currentAskRequest = manager.sharedPreferences.getStringSet( + UnSecurePrefKeys.currentAskRequest, + null + ) ?: emptySet() + + return processGrantedPermissionsAsync(requested = currentAskRequest, granted = grantedPermissions) } - private fun processGrantedPermissionsAsync(permissions: Set): Deferred { + private fun processGrantedPermissionsAsync(requested: Set, granted: Set): Deferred { val readGrants = readResources - .filter { permissions.containsAll(manager.permissionsRequiredToSyncResources(setOf(it))) } + .filter { granted.containsAll(manager.permissionsRequiredToSyncResources(setOf(it))) } .toSet() val writeGrants = writeResources - .filter { permissions.containsAll(manager.permissionsRequiredToWriteResources(setOf(it))) } + .filter { granted.containsAll(manager.permissionsRequiredToWriteResources(setOf(it))) } .toSet() manager.sharedPreferences.edit().run { @@ -74,14 +100,36 @@ class VitalPermissionRequestContract( // The activity result reports only permissions granted in this UI interaction. // Since we have VitalResources that are an aggregate of multiple record types, we need // to recompute based on the full set of permissions. - val discoveredNewGrants = manager.checkAndUpdatePermissions() + val (allGrants, discoveredNewGrants) = manager.checkAndUpdatePermissions() - // Asynchronously start syncing the newly granted read resources - taskScope.launch { - manager.syncData(readGrants + discoveredNewGrants) + val allNewGrants = readGrants + discoveredNewGrants + + if (allNewGrants.isNotEmpty()) { + // Asynchronously start syncing the newly granted read resources + taskScope.launch { + manager.syncData(allNewGrants) + } } - PermissionOutcome.Success + return@async if (allGrants.isEmpty() || allNewGrants.isEmpty()) { + // https://issuetracker.google.com/issues/233239418#comment2 + val notPromptThreshold = requested.all { permission -> + // We increment upfront, so if this is the 3rd attempt, the value + // would be 3 (post increment). + manager.sharedPreferences.getLong( + UnSecurePrefKeys.requestCount(permission), + 0 + ) >= 3 + } + + if (notPromptThreshold) { + PermissionOutcome.NotPrompted + } else { + PermissionOutcome.Success + } + } else { + PermissionOutcome.Success + } } } diff --git a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/PermissionOutcome.kt b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/PermissionOutcome.kt index 5ca0f9d5..74683d50 100644 --- a/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/PermissionOutcome.kt +++ b/VitalHealthConnect/src/main/java/io/tryvital/vitalhealthconnect/model/PermissionOutcome.kt @@ -1,7 +1,20 @@ package io.tryvital.vitalhealthconnect.model sealed interface PermissionOutcome { - object Success: PermissionOutcome - class Failure(cause: Throwable?): PermissionOutcome - object HealthConnectUnavailable: PermissionOutcome + object Success: PermissionOutcome { + override fun toString() = "success" + } + sealed class Failure(val cause: Throwable?): PermissionOutcome + object Cancelled: Failure(null) { + override fun toString() = "cancelled" + } + class UnknownError(cause: Throwable): Failure(cause) { + override fun toString() = "unknownError(${cause.toString()})" + } + object NotPrompted: Failure(null) { + override fun toString() = "notPrompted" + } + object HealthConnectUnavailable: PermissionOutcome { + override fun toString() = "healthDataUnavailable" + } } \ No newline at end of file diff --git a/app/src/main/java/io/tryvital/sample/ui/healthconnect/HealthConnectCard.kt b/app/src/main/java/io/tryvital/sample/ui/healthconnect/HealthConnectCard.kt index 60bce6b6..78eb302f 100644 --- a/app/src/main/java/io/tryvital/sample/ui/healthconnect/HealthConnectCard.kt +++ b/app/src/main/java/io/tryvital/sample/ui/healthconnect/HealthConnectCard.kt @@ -3,6 +3,7 @@ package io.tryvital.sample.ui.healthconnect import android.content.Intent import android.net.Uri import android.util.Log +import android.widget.Toast import android.widget.ToggleButton import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.* @@ -70,7 +71,7 @@ fun PermissionInfo( rememberLauncherForActivityResult(viewModel.createPermissionRequestContract()) { outcomeAsync -> coroutineScope.launch { val outcome = outcomeAsync.await() - Log.i("VitalPermissionOutcome", outcome.toString()) + Toast.makeText(context, outcome.toString(), Toast.LENGTH_LONG).show() viewModel.checkPermissions() } }