diff --git a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt index 968facb..7a137c2 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt @@ -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 @@ -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 { diff --git a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt index ea833e7..db82103 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt @@ -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) diff --git a/android/src/main/java/dev/matinzd/healthconnect/permissions/HealthConnectPermissionDelegate.kt b/android/src/main/java/dev/matinzd/healthconnect/permissions/HealthConnectPermissionDelegate.kt index a6b6ed4..5ec3641 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/permissions/HealthConnectPermissionDelegate.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/permissions/HealthConnectPermissionDelegate.kt @@ -3,6 +3,8 @@ 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 @@ -10,26 +12,42 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch object HealthConnectPermissionDelegate { - private lateinit var requestPermission: ActivityResultLauncher> - private val channel = Channel>() private val coroutineScope = CoroutineScope(Dispatchers.IO) + private val permissionsChannel = Channel>() + private val exerciseRouteChannel = Channel() + + private lateinit var requestPermission: ActivityResultLauncher> + private lateinit var requestRoutePermission: ActivityResultLauncher 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): Set { + suspend fun launchPermissionsDialog(permissions: Set): Set { requestPermission.launch(permissions) - return channel.receive() + return permissionsChannel.receive() + } + + suspend fun launchExerciseRouteAccessRequestDialog(recordId: String): ExerciseRoute? { + requestRoutePermission.launch(recordId) + return exerciseRouteChannel.receive() } } diff --git a/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt b/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt index 1ab12ad..55bc7a5 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt @@ -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 diff --git a/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt b/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt index be85f4a..9de4059 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt @@ -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.* @@ -103,36 +104,26 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl }) + 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)) } @@ -203,4 +194,21 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl } } } + + 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) + } + } + } + } } diff --git a/android/src/main/java/dev/matinzd/healthconnect/utils/ExceptionsUtils.kt b/android/src/main/java/dev/matinzd/healthconnect/utils/ExceptionsUtils.kt index 8b860c9..af358a8 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/utils/ExceptionsUtils.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/utils/ExceptionsUtils.kt @@ -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") diff --git a/android/src/oldarch/HealthConnectSpec.kt b/android/src/oldarch/HealthConnectSpec.kt index 88c48a7..85a3bd6 100644 --- a/android/src/oldarch/HealthConnectSpec.kt +++ b/android/src/oldarch/HealthConnectSpec.kt @@ -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); diff --git a/docs/docs/api/methods/03-requestPermission.md b/docs/docs/api/methods/03-requestPermission.md index 7cf99eb..44a6fc3 100644 --- a/docs/docs/api/methods/03-requestPermission.md +++ b/docs/docs/api/methods/03-requestPermission.md @@ -9,7 +9,7 @@ Request permission for specified record types and access types. # Method ```ts -requestPermission(permissions: Permission[], providerPackageName: string): Promise +requestPermission(permissions: Permission[]): Promise ``` # Example @@ -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 }); + }); +}; +``` \ No newline at end of file diff --git a/docs/docs/api/methods/16-requestExerciseRoute.md b/docs/docs/api/methods/16-requestExerciseRoute.md new file mode 100644 index 0000000..db9e8e5 --- /dev/null +++ b/docs/docs/api/methods/16-requestExerciseRoute.md @@ -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 + + + +... + +``` + +# Method + +```ts +requestExerciseRoute(recordId: string): Promise +``` + +# 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 }); + }); + +``` \ No newline at end of file diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 63b91c2..f483f4a 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + { + const route: Location[] = []; + for (let i = 0; i < 10; i++) { + route.push({ + latitude: 37.785834 + Math.random() * 0.01, + longitude: -122.406417 + Math.random() * 0.01, + altitude: { + value: 0 + Math.random() * 100, + unit: 'meters', + }, + horizontalAccuracy: { + value: 0 + Math.random() * 10, + unit: 'meters', + }, + verticalAccuracy: { + value: 0 + Math.random() * 10, + unit: 'meters', + }, + time: new Date(startTime.getTime() + i * 1000).toISOString(), + }); + } + return route; +}; const getBeginningOfLast7Days = () => { const date = new Date(); @@ -44,6 +78,14 @@ const random64BitString = () => { }; export default function App() { + const [recordId, setRecordId] = React.useState(); + + const updateRecordId = ( + e: NativeSyntheticEvent + ) => { + setRecordId(e.nativeEvent.text); + }; + const initializeHealthConnect = async () => { const result = await initialize(); console.log({ result }); @@ -215,6 +257,10 @@ export default function App() { accessType: 'read', recordType: 'ExerciseSession', }, + { + accessType: 'write', + recordType: 'ExerciseRoute', + }, ]).then((permissions) => { console.log('Granted permissions on request ', { permissions }); }); @@ -226,6 +272,68 @@ export default function App() { }); }; + const insertRandomExercise = () => { + const startTime = new Date( + Date.now() - Math.random() * 1000 * 60 * 60 * 48 + ); + insertRecords([ + { + recordType: 'ExerciseSession', + startTime: startTime.toISOString(), + endTime: new Date(startTime.getTime() + 1000 * 60 * 10).toISOString(), // 10 minutes + metadata: { + clientRecordId: random64BitString(), + recordingMethod: + RecordingMethod.RECORDING_METHOD_AUTOMATICALLY_RECORDED, + device: { + manufacturer: 'Google', + model: 'Pixel 4', + type: DeviceType.TYPE_PHONE, + }, + }, + exerciseType: ExerciseType.RUNNING, + exerciseRoute: { route: generateExerciseRoute(startTime) }, + title: 'Morning Run - v' + Math.random().toFixed(2).toString(), + }, + ]) + .then((ids) => { + console.log('Records inserted ', { ids }); + }) + .catch((err) => { + console.error('Error inserting records ', { err }); + }); + }; + + const readExercise = () => { + if (!recordId) { + console.error('Record ID is required'); + return; + } + + readRecord('ExerciseSession', recordId) + .then((exercise) => { + console.log('Exercise record: ', JSON.stringify(exercise, null, 2)); + }) + .catch((err) => { + console.error('Error reading exercise record ', { err }); + }); + }; + + const readExerciseRoute = () => { + if (!recordId) { + console.error('Record ID is required'); + return; + } + + requestExerciseRoute(recordId) + .then((route) => { + console.log('Exercise route: ', { route }); + }) + .catch((err) => { + console.error('Error reading exercise route ', { err }); + }); + }; + return (