Skip to content

Commit

Permalink
VIT-6350: Add HC Ask NotPrompted case
Browse files Browse the repository at this point in the history
  • Loading branch information
andersio committed Apr 23, 2024
1 parent 1794f08 commit 4e1e164
Show file tree
Hide file tree
Showing 5 changed files with 75 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ class VitalHealthConnectManager private constructor(
.toSet()
}

internal suspend fun checkAndUpdatePermissions(): Set<VitalResource> {
internal suspend fun checkAndUpdatePermissions(): Pair<Set<VitalResource>, Set<VitalResource>> {
if (isAvailable(context) != HealthConnectAvailability.Installed) {
return setOf()
return emptySet<VitalResource>() to emptySet()
}

val lastKnownGrantedResources = resourcesWithReadPermission()
Expand All @@ -205,7 +205,7 @@ class VitalHealthConnectManager private constructor(
apply()
}

return upToDateGrantedResources - lastKnownGrantedResources
return upToDateGrantedResources to upToDateGrantedResources - lastKnownGrantedResources
}

internal fun permissionsRequiredToWriteResources(resources: Set<WritableVitalResource>): Set<String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<PermissionOutcome> {
val grantedPermissions = contract.parseResult(resultCode, intent)

if (intent == null) {
return CompletableDeferred(
PermissionOutcome.Failure(cause = IllegalStateException("Missing intent in parseResult."))
)
val cause = when (resultCode) {
0 -> CancellationException()
else -> IllegalStateException("Missing intent in parseResult.")
}
return CompletableDeferred(PermissionOutcome.Cancelled(cause = cause))
}

return processGrantedPermissionsAsync(grantedPermissions)
val currentAskRequest = manager.sharedPreferences.getStringSet(
UnSecurePrefKeys.currentAskRequest,
null
) ?: emptySet()

return processGrantedPermissionsAsync(requested = currentAskRequest, granted = grantedPermissions)
}

private fun processGrantedPermissionsAsync(permissions: Set<String>): Deferred<PermissionOutcome> {
private fun processGrantedPermissionsAsync(requested: Set<String>, granted: Set<String>): Deferred<PermissionOutcome> {
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 {
Expand All @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package io.tryvital.vitalhealthconnect.model

sealed interface PermissionOutcome {
object Success: PermissionOutcome
class Failure(cause: Throwable?): PermissionOutcome
sealed class Failure(cause: Throwable?): PermissionOutcome
class Cancelled(cause: Throwable?): Failure(cause)
object NotPrompted: Failure(null)
object HealthConnectUnavailable: PermissionOutcome
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down Expand Up @@ -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()
}
}
Expand Down

0 comments on commit 4e1e164

Please sign in to comment.