Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VIT-6350: Add HC Ask NotPrompted case #112

Merged
merged 1 commit into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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<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
@@ -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"
}
}
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
Loading