From 1aea82358b492211151765e9df9443f05045e6ba Mon Sep 17 00:00:00 2001 From: Ugur Akin Date: Wed, 9 Oct 2024 16:01:12 +0100 Subject: [PATCH 01/12] feat: adds a new method for requesting exercise permissions --- .../healthconnect/HealthConnectManager.kt | 20 +++- .../healthconnect/HealthConnectModule.kt | 9 ++ .../HealthConnectPermissionDelegate.kt | 28 +++++- .../permissions/PermissionUtils.kt | 17 +++- .../records/ReactExerciseSessionRecord.kt | 35 ++++--- .../healthconnect/utils/ExceptionsUtils.kt | 1 + android/src/oldarch/HealthConnectSpec.kt | 56 ++++++----- .../android/app/src/main/AndroidManifest.xml | 2 + example/src/App.tsx | 97 ++++++++++++++++++- src/NativeHealthConnect.ts | 2 + src/index.tsx | 8 +- 11 files changed, 226 insertions(+), 49 deletions(-) diff --git a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectManager.kt index 8440535..ba59c02 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 @@ -66,12 +68,28 @@ class HealthConnectManager(private val applicationContext: ReactApplicationConte ) { 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 ea871a8..ae472eb 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/HealthConnectModule.kt @@ -40,6 +40,15 @@ class HealthConnectModule internal constructor(context: ReactApplicationContext) return manager.requestPermission(permissions, providerPackageName, 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..0d3607b 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/permissions/PermissionUtils.kt @@ -1,7 +1,9 @@ package dev.matinzd.healthconnect.permissions +import android.util.Log import androidx.health.connect.client.PermissionController import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ExerciseSessionRecord import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.WritableNativeArray import dev.matinzd.healthconnect.utils.InvalidRecordType @@ -10,7 +12,7 @@ import dev.matinzd.healthconnect.utils.reactRecordTypeToClassMap class PermissionUtils { companion object { fun parsePermissions(reactPermissions: ReadableArray): Set { - return reactPermissions.toArrayList().mapNotNull { + val setOfPermissions = reactPermissions.toArrayList().mapNotNull { it as HashMap<*, *> val recordType = it["recordType"] val recordClass = reactRecordTypeToClassMap[recordType] @@ -22,6 +24,19 @@ class PermissionUtils { else -> null } }.toSet() + + val containsExercise = setOfPermissions.contains(HealthPermission.getWritePermission(ExerciseSessionRecord::class)) + Log.d("PermissionUtils", "Set of permissions are: " + setOfPermissions.toTypedArray().joinToString(", ")) + Log.d("PermissionUtils", "WritePermission we're checking is " + HealthPermission.getWritePermission(ExerciseSessionRecord::class)) + Log.d("PermissionUtils", "Contains is $containsExercise") + + val shouldAskForRoute = true // TODO + if(containsExercise && shouldAskForRoute) { + return setOfPermissions.plus(HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE) + } + else { + return setOfPermissions + } } suspend fun getGrantedPermissions(permissionController: PermissionController): WritableNativeArray { 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 2f01df1..e060ed3 100644 --- a/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt +++ b/android/src/main/java/dev/matinzd/healthconnect/records/ReactExerciseSessionRecord.kt @@ -6,7 +6,6 @@ import androidx.health.connect.client.records.ExerciseRoute import androidx.health.connect.client.records.ExerciseRouteResult import androidx.health.connect.client.records.ExerciseSegment import androidx.health.connect.client.records.ExerciseSessionRecord -import androidx.health.connect.client.records.PowerRecord import androidx.health.connect.client.request.AggregateRequest import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap @@ -100,19 +99,8 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl 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) - } - }) + val exerciseRoute: ExerciseRoute = (record.exerciseRouteResult as ExerciseRouteResult.Data).exerciseRoute + val exerciseRouteMap = parseExerciseRoute(exerciseRoute) putMap("exerciseRoute", exerciseRouteMap) } @@ -153,4 +141,23 @@ class ReactExerciseSessionRecord : ReactHealthRecordImpl putArray("dataOrigins", convertDataOriginsToJsArray(record.dataOrigins)) } } + + companion object { + fun parseExerciseRoute(exerciseRoute: ExerciseRoute): WritableNativeMap { + return WritableNativeMap().apply { + putArray("route", 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 9363122..0cdd7de 100644 --- a/android/src/oldarch/HealthConnectSpec.kt +++ b/android/src/oldarch/HealthConnectSpec.kt @@ -3,47 +3,51 @@ package dev.matinzd.healthconnect import com.facebook.react.bridge.* abstract class HealthConnectSpec internal constructor(context: ReactApplicationContext) : - ReactContextBaseJavaModule(context) { + ReactContextBaseJavaModule(context) { - @ReactMethod - abstract fun getSdkStatus(providerPackageName: String, promise: Promise) + @ReactMethod abstract fun getSdkStatus(providerPackageName: String, promise: Promise) - @ReactMethod - abstract fun initialize(providerPackageName: String, promise: Promise); + @ReactMethod abstract fun initialize(providerPackageName: String, promise: Promise) - @ReactMethod - abstract fun openHealthConnectSettings(); + @ReactMethod abstract fun openHealthConnectSettings() - @ReactMethod - abstract fun openHealthConnectDataManagement(providerPackageName: String?); + @ReactMethod abstract fun openHealthConnectDataManagement(providerPackageName: String?) @ReactMethod - abstract fun requestPermission(permissions: ReadableArray, providerPackageName: String, promise: Promise); + abstract fun requestPermission( + permissions: ReadableArray, + providerPackageName: String, + promise: Promise + ) - @ReactMethod - abstract fun getGrantedPermissions(promise: Promise); + @ReactMethod abstract fun requestExerciseRoute(recordId: String, promise: Promise) - @ReactMethod - abstract fun revokeAllPermissions(promise: Promise); + @ReactMethod abstract fun getGrantedPermissions(promise: Promise) - @ReactMethod - abstract fun insertRecords(records: ReadableArray, promise: Promise); + @ReactMethod abstract fun revokeAllPermissions(promise: Promise) - @ReactMethod - abstract fun readRecords(recordType: String, options: ReadableMap, promise: Promise); + @ReactMethod abstract fun insertRecords(records: ReadableArray, promise: Promise) - @ReactMethod - abstract fun readRecord(recordType: String, recordId: String, promise: Promise); + @ReactMethod abstract fun readRecords(recordType: String, options: ReadableMap, promise: Promise) - @ReactMethod - abstract fun aggregateRecord(record: ReadableMap, promise: Promise); + @ReactMethod abstract fun readRecord(recordType: String, recordId: String, promise: Promise) - @ReactMethod - abstract fun getChanges(options: ReadableMap, promise: Promise); + @ReactMethod abstract fun aggregateRecord(record: ReadableMap, promise: Promise) + + @ReactMethod abstract fun getChanges(options: ReadableMap, promise: Promise) @ReactMethod - abstract fun deleteRecordsByUuids(recordType: String, recordIdsList: ReadableArray, clientRecordIdsList: ReadableArray, promise: Promise); + abstract fun deleteRecordsByUuids( + recordType: String, + recordIdsList: ReadableArray, + clientRecordIdsList: ReadableArray, + promise: Promise + ) @ReactMethod - abstract fun deleteRecordsByTimeRange(recordType: String, timeRangeFilter: ReadableMap, promise: Promise); + abstract fun deleteRecordsByTimeRange( + recordType: String, + timeRangeFilter: ReadableMap, + promise: Promise + ) } 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 getLastWeekDate = (): Date => { return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000); @@ -35,6 +69,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 }); @@ -152,6 +194,51 @@ export default function App() { }); }; + const insertRandomExercise = () => { + const startTime = new Date('2021-09-01T00:00:00.000Z'); + 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 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 (