Skip to content

Commit

Permalink
Request permissions
Browse files Browse the repository at this point in the history
  • Loading branch information
vitoksmile committed Jul 19, 2023
1 parent 9993e77 commit c631fd8
Show file tree
Hide file tree
Showing 15 changed files with 346 additions and 11 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# HealthKMM

Kotlin Multiplatform Mobile wrapper for HealthKit on iOS and Google Fit and Health Connect on Android.
Kotlin Multiplatform Mobile wrapper for HealthKit on iOS and ~~Google Fit and~~ Health Connect on Android.

> Google Fitness API is being deprecated and Health Connect the plugin will transition into the API as the Health Connect
The library supports:
- handling permissions to access health data using the `isAvailable`, `isAuthorized`, `requestAuthorization`, `revokeAuthorization` methods.

Note that for Android, the target phone **needs** to have ~~[Google Fit](https://www.google.com/fit/) or~~ [Health Connect](https://health.google/health-connect-android/) (which is currently in beta) installed and have access to the internet, otherwise this library will not work.

## Data Types
- STEPS
- WEIGHT
2 changes: 1 addition & 1 deletion androidApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name="com.vitoksmile.kmm.health.MainActivity"
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
android:exported="true">
<intent-filter>
Expand Down
4 changes: 4 additions & 0 deletions core/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
</queries>

<application>
<activity
android:name=".HealthConnectPermissionActivity"
android:theme="@style/HealthConnectPermissionTheme" />

<!-- Initializer for ApplicationContextHolder -->
<provider
android:name="androidx.startup.InitializationProvider"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.vitoksmile.kmm.health

import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.WeightRecord

internal fun HealthDataType.toHealthPermission(
isRead: Boolean = false,
isWrite: Boolean = false
): String {
require(isRead != isWrite)

return (when (this) {
HealthDataType.STEPS -> StepsRecord::class
HealthDataType.WEIGHT -> WeightRecord::class
}).let {
if (isRead) {
HealthPermission.getReadPermission(it)
} else {
HealthPermission.getWritePermission(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,59 @@ package com.vitoksmile.kmm.health

import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import kotlinx.coroutines.CancellationException

class HealthConnectManager(
private val context: Context,
) : HealthManager {

private val healthConnectClient by lazy { HealthConnectClient.getOrCreate(context) }

override fun isAvailable(): Result<Boolean> = runCatching {
val status = HealthConnectClient.getSdkStatus(context)
status == HealthConnectClient.SDK_AVAILABLE
}
}

override suspend fun isAuthorized(
readTypes: List<HealthDataType>,
writeTypes: List<HealthDataType>
): Result<Boolean> = runCatching {
val grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions()

grantedPermissions.containsAll(readTypes.readPermissions) &&
grantedPermissions.containsAll(writeTypes.writePermissions)
}

override suspend fun requestAuthorization(
readTypes: List<HealthDataType>,
writeTypes: List<HealthDataType>,
): Result<Boolean> =
isAuthorized(readTypes = readTypes, writeTypes = writeTypes)
.mapCatching { isAuthorized ->
if (isAuthorized) return@mapCatching true

try {
HealthConnectPermissionActivity.request(
context,
readPermissions = readTypes.readPermissions,
writePermissions = writeTypes.writePermissions,
).getOrThrow()
} catch (ignored: CancellationException) {
false
} catch (ex: Throwable) {
throw ex
}
}

override suspend fun isRevokeAuthorizationSupported(): Result<Boolean> = Result.success(true)

override suspend fun revokeAuthorization(): Result<Unit> = runCatching {
healthConnectClient.permissionController.revokeAllPermissions()
}
}

private val List<HealthDataType>.readPermissions: Set<String>
get() = map { it.toHealthPermission(isRead = true) }.toSet()

private val List<HealthDataType>.writePermissions: Set<String>
get() = map { it.toHealthPermission(isWrite = true) }.toSet()
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.vitoksmile.kmm.health

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.health.connect.client.PermissionController
import kotlin.coroutines.resume
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine

/**
* Health Connect permissions dialog not shown on subsequent requests
*
* https://issuetracker.google.com/issues/233239418
*/
internal class HealthConnectPermissionActivity : AppCompatActivity() {

companion object {
private const val KEY_READ_PERMISSIONS = "KEY_READ_PERMISSIONS"
private const val KEY_WRITE_PERMISSIONS = "KEY_WRITE_PERMISSIONS"

private var continuation: CancellableContinuation<Result<Boolean>>? = null

suspend fun request(
context: Context,
readPermissions: Set<String>,
writePermissions: Set<String>,
): Result<Boolean> = suspendCancellableCoroutine {
continuation?.cancel()
continuation = it

context.startActivity(
Intent(context, HealthConnectPermissionActivity::class.java)
.putExtra(KEY_READ_PERMISSIONS, readPermissions.toTypedArray())
.putExtra(KEY_WRITE_PERMISSIONS, writePermissions.toTypedArray())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}

private val permissions: Set<String> by lazy {
val readPermissions = intent.getStringArrayExtra(KEY_READ_PERMISSIONS).orEmpty().toSet()
val writePermissions = intent.getStringArrayExtra(KEY_WRITE_PERMISSIONS).orEmpty().toSet()
readPermissions + writePermissions
}

private val contract = PermissionController.createRequestPermissionResultContract()
private val requestPermissions = registerForActivityResult(contract) { grantedPermissions ->
val granted = grantedPermissions.containsAll(permissions)
continuation?.resume(Result.success(granted))
continuation = null
finish()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val readPermissions = intent.getStringArrayExtra(KEY_READ_PERMISSIONS).orEmpty().toSet()
val writePermissions = intent.getStringArrayExtra(KEY_WRITE_PERMISSIONS).orEmpty().toSet()
val permissions = readPermissions + writePermissions
requestPermissions.launch(permissions)
}

override fun onDestroy() {
super.onDestroy()
continuation?.cancel()
continuation = null
}
}
13 changes: 13 additions & 0 deletions core/src/androidMain/res/values/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Transparent and no animation -->
<style name="HealthConnectPermissionTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowAnimationStyle">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
75 changes: 71 additions & 4 deletions core/src/commonMain/kotlin/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,45 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitoksmile.kmm.health.HealthDataType
import com.vitoksmile.kmm.health.HealthManagerFactory
import kotlinx.coroutines.launch

@Composable
fun App() {
val healthManager = remember {
HealthManagerFactory().createManager()
val coroutineScope = rememberCoroutineScope()
val healthManager = remember { HealthManagerFactory().createManager() }

val readTypes = remember { listOf(HealthDataType.STEPS) }
val writeTypes = remember { listOf(HealthDataType.WEIGHT) }

var isAvailableResult by remember { mutableStateOf(Result.success(false)) }
var isAuthorizedResult by remember { mutableStateOf<Result<Boolean>?>(null) }
var isRevokeSupported by remember { mutableStateOf(false) }

LaunchedEffect(healthManager) {
isAvailableResult = healthManager.isAvailable()

if (isAvailableResult.getOrNull() == false) return@LaunchedEffect
isAuthorizedResult = healthManager.isAuthorized(
readTypes = readTypes,
writeTypes = writeTypes,
)
isRevokeSupported = healthManager.isRevokeAuthorizationSupported().getOrNull() ?: false
}

MaterialTheme {
Expand All @@ -24,12 +51,52 @@ fun App() {
) {
Text("Hello, this is HealthKMM for ${getPlatformName()}")

healthManager.isAvailable()
isAvailableResult
.onSuccess { isAvailable ->
Text("HealthManager isAvailable=$isAvailable")
}
.onFailure {
Text("HealthManager error=${it.message}")
Text("HealthManager isAvailable=${it.message}")
}

isAuthorizedResult
?.onSuccess {
Text("HealthManager isAuthorized=$it")
}
?.onFailure {
Text("HealthManager isAuthorized=${it.message}")
}
if (isAvailableResult.getOrNull() == true && isAuthorizedResult?.getOrNull() == false)
Button(
onClick = {
coroutineScope.launch {
isAuthorizedResult = healthManager.requestAuthorization(
readTypes = readTypes,
writeTypes = writeTypes,
)
}
},
) {
Text("Request authorization")
}

if (isAvailableResult.getOrNull() == true && isRevokeSupported && isAuthorizedResult?.getOrNull() == true)
Button(
onClick = {
coroutineScope.launch {
healthManager.revokeAuthorization()
isAuthorizedResult = healthManager.isAuthorized(
readTypes = readTypes,
writeTypes = writeTypes,
)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red,
contentColor = Color.White,
),
) {
Text("Revoke authorization")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.vitoksmile.kmm.health

enum class HealthDataType {
STEPS,
WEIGHT,
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,18 @@ package com.vitoksmile.kmm.health
interface HealthManager {

fun isAvailable(): Result<Boolean>

suspend fun isAuthorized(
readTypes: List<HealthDataType>,
writeTypes: List<HealthDataType>,
): Result<Boolean>

suspend fun requestAuthorization(
readTypes: List<HealthDataType>,
writeTypes: List<HealthDataType>,
): Result<Boolean>

suspend fun isRevokeAuthorizationSupported(): Result<Boolean>

suspend fun revokeAuthorization(): Result<Unit>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.vitoksmile.kmm.health

import platform.HealthKit.HKQuantityType
import platform.HealthKit.HKQuantityTypeIdentifierBodyMass
import platform.HealthKit.HKQuantityTypeIdentifierStepCount
import platform.HealthKit.HKSampleType

internal fun HealthDataType.toHKSampleType(): HKSampleType? = when (this) {
HealthDataType.STEPS ->
HKQuantityType.quantityTypeForIdentifier(HKQuantityTypeIdentifierStepCount)

HealthDataType.WEIGHT ->
HKQuantityType.quantityTypeForIdentifier(HKQuantityTypeIdentifierBodyMass)
}
Loading

0 comments on commit c631fd8

Please sign in to comment.