Skip to content

Commit

Permalink
feat: adds a new method for requesting exercise permissions (#167)
Browse files Browse the repository at this point in the history
* feat: adds a new method for requesting exercise permissions

* feat: changes request permission interface to include a flag for route permissions

* chore: reverted code formatting

* chore: remove merge cursor

* chore: reverted more formatting changes

* chore: minor diff clean up

* feat: bubbles up exercise route result type

* feat: bubbles up consent + adds a permission type for exercise route

* chore: removed unused imports and log statements

* docs: corrected comment

* docs: updated docs for request permission

* docs: added docs for the new api
  • Loading branch information
ugurakin1 authored Nov 10, 2024
1 parent 400b849 commit 3cbe841
Show file tree
Hide file tree
Showing 15 changed files with 330 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate
import dev.matinzd.healthconnect.permissions.PermissionUtils
import dev.matinzd.healthconnect.records.ReactExerciseSessionRecord
import dev.matinzd.healthconnect.records.ReactHealthRecord
import dev.matinzd.healthconnect.utils.ClientNotInitialized
import dev.matinzd.healthconnect.utils.ExerciseRouteAccessDenied
import dev.matinzd.healthconnect.utils.convertChangesTokenRequestOptionsFromJS
import dev.matinzd.healthconnect.utils.getTimeRangeFilter
import dev.matinzd.healthconnect.utils.reactRecordTypeToClassMap
Expand Down Expand Up @@ -62,16 +64,33 @@ class HealthConnectManager(private val applicationContext: ReactApplicationConte
}

fun requestPermission(
reactPermissions: ReadableArray, providerPackageName: String, promise: Promise
reactPermissions: ReadableArray,
promise: Promise
) {
throwUnlessClientIsAvailable(promise) {
coroutineScope.launch {
val granted = HealthConnectPermissionDelegate.launch(PermissionUtils.parsePermissions(reactPermissions))
val granted = HealthConnectPermissionDelegate.launchPermissionsDialog(PermissionUtils.parsePermissions(reactPermissions))
promise.resolve(PermissionUtils.mapPermissionResult(granted))
}
}
}

fun requestExerciseRoute(
recordId: String, promise: Promise
) {
throwUnlessClientIsAvailable(promise) {
coroutineScope.launch {
val exerciseRoute = HealthConnectPermissionDelegate.launchExerciseRouteAccessRequestDialog(recordId)
if (exerciseRoute != null) {
promise.resolve(ReactExerciseSessionRecord.parseExerciseRoute(exerciseRoute))
}
else{
promise.rejectWithException(ExerciseRouteAccessDenied())
}
}
}
}

fun revokeAllPermissions(promise: Promise) {
throwUnlessClientIsAvailable(promise) {
coroutineScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,20 @@ class HealthConnectModule internal constructor(context: ReactApplicationContext)
@ReactMethod
override fun requestPermission(
permissions: ReadableArray,
providerPackageName: String,
promise: Promise
) {
return manager.requestPermission(permissions, providerPackageName, promise)
return manager.requestPermission(permissions, promise)
}

@ReactMethod
override fun requestExerciseRoute(
recordId: String,
promise: Promise
) {
return manager.requestExerciseRoute(recordId, promise)
}


@ReactMethod
override fun getGrantedPermissions(promise: Promise) {
return manager.getGrantedPermissions(promise)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,51 @@ package dev.matinzd.healthconnect.permissions
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.contracts.ExerciseRouteRequestContract
import androidx.health.connect.client.records.ExerciseRoute
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch

object HealthConnectPermissionDelegate {
private lateinit var requestPermission: ActivityResultLauncher<Set<String>>
private val channel = Channel<Set<String>>()
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val permissionsChannel = Channel<Set<String>>()
private val exerciseRouteChannel = Channel<ExerciseRoute?>()

private lateinit var requestPermission: ActivityResultLauncher<Set<String>>
private lateinit var requestRoutePermission: ActivityResultLauncher<String>

fun setPermissionDelegate(
activity: ComponentActivity,
providerPackageName: String = "com.google.android.apps.healthdata"
) {
val contract = PermissionController.createRequestPermissionResultContract(providerPackageName)
val exerciseRouteRequestContract = ExerciseRouteRequestContract()

requestPermission = activity.registerForActivityResult(contract) {
coroutineScope.launch {
channel.send(it)
permissionsChannel.send(it)
coroutineContext.cancel()
}
}

requestRoutePermission = activity.registerForActivityResult(exerciseRouteRequestContract) {
coroutineScope.launch {
exerciseRouteChannel.send(it)
coroutineContext.cancel()
}
}
}

suspend fun launch(permissions: Set<String>): Set<String> {
suspend fun launchPermissionsDialog(permissions: Set<String>): Set<String> {
requestPermission.launch(permissions)
return channel.receive()
return permissionsChannel.receive()
}

suspend fun launchExerciseRouteAccessRequestDialog(recordId: String): ExerciseRoute? {
requestRoutePermission.launch(recordId)
return exerciseRouteChannel.receive()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@ class PermissionUtils {
return reactPermissions.toArrayList().mapNotNull {
it as HashMap<*, *>
val recordType = it["recordType"]
val accessType = it["accessType"]

if (accessType == "write" && recordType == "ExerciseRoute") {
return@mapNotNull HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE
}

val recordClass = reactRecordTypeToClassMap[recordType]
?: throw InvalidRecordType()

when (it["accessType"]) {
when (accessType) {
"write" -> HealthPermission.getWritePermission(recordClass)
"read" -> HealthPermission.getReadPermission(recordClass)
else -> null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
import androidx.health.connect.client.request.AggregateRequest
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableNativeArray
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import dev.matinzd.healthconnect.utils.*
Expand Down Expand Up @@ -103,36 +104,26 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl<ExerciseSessionRecord>
})


val exerciseRouteMap = WritableNativeMap()
when (record.exerciseRouteResult) {
is ExerciseRouteResult.Data -> {
val exerciseRouteMap = WritableNativeMap()
exerciseRouteMap.putArray("route", WritableNativeArray().apply {
(record.exerciseRouteResult as ExerciseRouteResult.Data).exerciseRoute.route.map {
val map = WritableNativeMap()
map.putString("time", it.time.toString())
map.putDouble("latitude", it.latitude)
map.putDouble("longitude", it.longitude)
map.putMap("horizontalAccuracy", lengthToJsMap(it.horizontalAccuracy))
map.putMap("verticalAccuracy", lengthToJsMap(it.verticalAccuracy))
map.putMap("altitude", lengthToJsMap(it.altitude))
this.pushMap(map)
}
})
putMap("exerciseRoute", exerciseRouteMap)
val exerciseRoute: ExerciseRoute =
(record.exerciseRouteResult as ExerciseRouteResult.Data).exerciseRoute
val route = parseExerciseRoute(exerciseRoute)
exerciseRouteMap.putString("type", "DATA")
exerciseRouteMap.putArray("route", route)
}

is ExerciseRouteResult.NoData -> {
putMap("exerciseRoute", WritableNativeMap())
}
exerciseRouteMap.putString("type", "NO_DATA")
exerciseRouteMap.putArray("route", WritableNativeArray())

is ExerciseRouteResult.ConsentRequired -> {
throw Exception("Consent required")
}

else -> {
putMap("exerciseRoute", WritableNativeMap())
is ExerciseRouteResult.ConsentRequired -> {
exerciseRouteMap.putString("type", "CONSENT_REQUIRED")
exerciseRouteMap.putArray("route", WritableNativeArray())
}
}
putMap("exerciseRoute", exerciseRouteMap)

putMap("metadata", convertMetadataToJSMap(record.metadata))
}
Expand Down Expand Up @@ -203,4 +194,21 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl<ExerciseSessionRecord>
}
}
}

companion object {
fun parseExerciseRoute(exerciseRoute: ExerciseRoute): ReadableNativeArray {
return WritableNativeArray().apply {
exerciseRoute.route.map {
val map = WritableNativeMap()
map.putString("time", it.time.toString())
map.putDouble("latitude", it.latitude)
map.putDouble("longitude", it.longitude)
map.putMap("horizontalAccuracy", lengthToJsMap(it.horizontalAccuracy))
map.putMap("verticalAccuracy", lengthToJsMap(it.verticalAccuracy))
map.putMap("altitude", lengthToJsMap(it.altitude))
this.pushMap(map)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.RemoteException
import com.facebook.react.bridge.Promise
import okio.IOException

class ExerciseRouteAccessDenied : Exception("Request to access exercise route denied")
class ClientNotInitialized : Exception("Health Connect client is not initialized")
class InvalidRecordType : Exception("Record type is not valid")
class InvalidTemperature : Exception("Temperature is not valid")
Expand Down
5 changes: 4 additions & 1 deletion android/src/oldarch/HealthConnectSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ abstract class HealthConnectSpec internal constructor(context: ReactApplicationC
abstract fun openHealthConnectDataManagement(providerPackageName: String?);

@ReactMethod
abstract fun requestPermission(permissions: ReadableArray, providerPackageName: String, promise: Promise);
abstract fun requestPermission(permissions: ReadableArray, promise: Promise);

@ReactMethod
abstract fun requestExerciseRoute(recordId: String, promise: Promise);

@ReactMethod
abstract fun getGrantedPermissions(promise: Promise);
Expand Down
27 changes: 26 additions & 1 deletion docs/docs/api/methods/03-requestPermission.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Request permission for specified record types and access types.
# Method

```ts
requestPermission(permissions: Permission[], providerPackageName: string): Promise<Permission[]>
requestPermission(permissions: Permission[]): Promise<Permission[]>
```

# Example
Expand All @@ -32,3 +32,28 @@ const requestPermissions = () => {
});
};
```

If your app needs to write exercise routes, can include it as a special permission:

```ts
import { requestPermission } from 'react-native-health-connect';

const requestPermissions = () => {
requestPermission([
{
accessType: 'read',
recordType: 'ExerciseSession',
},
{
accessType: 'write',
recordType: 'ExerciseSession',
},
{
accessType: 'write',
recordType: 'ExerciseRoute'
}
]).then((permissions) => {
console.log('Granted permissions ', { permissions });
});
};
```
59 changes: 59 additions & 0 deletions docs/docs/api/methods/16-requestExerciseRoute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: requestExerciseRoute
---

# `requestExerciseRoute`

Health Connect requires users' permission to access routes for exercise records ([see Android docs](https://developer.android.com/health-and-fitness/guides/health-connect/develop/exercise-routes)). When exercise records are fetched, they will include an `exerciseRoute` field with a `type` (and possibly a `route`). This method should be called to request permissions to fetch the route if the `type` is `ExerciseRouteResultType.CONSENT_REQUIRED`.

NOTE: To read exercise routes, you need to declare the required permissions in your app's `AndroidManifest.xml`:

```xml
<application>
<uses-permission
android:name="android.permission.health.READ_EXERCISE_ROUTES" />
<uses-permission
android:name="android.permission.health.READ_EXERCISE" />
...
</application>
```

# Method

```ts
requestExerciseRoute(recordId: string): Promise<ExerciseRoute>
```

# Example

```ts
import {
requestExerciseRoute,
readRecord,
ExerciseRouteResultType,
} from "react-native-health-connect";

const recordId = "6bd8109d-349b-319a-890a-c5a20902b530";

readRecord("ExerciseSession", recordId)
.then((exercise) => {
console.log("Exercise record: ", JSON.stringify(exercise, null, 2));

// Check if consent is required to read route:
if (
exercise.exerciseRoute.type === ExerciseRouteResultType.CONSENT_REQUIRED
) {
requestExerciseRoute(recordId).then(({ route }) => {
if (route) {
console.log(JSON.stringify(route, null, 2));
} else {
console.log("User denied access");
}
});
}
})
.catch((err) => {
console.error("Error reading exercise record", { err });
});

```
2 changes: 2 additions & 0 deletions example/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.health.WRITE_STEPS" />
<uses-permission android:name="android.permission.health.READ_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.READ_EXERCISE_ROUTES"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE"/>

<application
android:name=".MainApplication"
Expand Down
Loading

0 comments on commit 3cbe841

Please sign in to comment.