diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index ea6c522d4..c6a3d82a7 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -25,14 +25,14 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 34 + compileSdk 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { minSdkVersion 28 - targetSdkVersion 33 + targetSdkVersion 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { @@ -46,7 +46,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation("com.google.android.gms:play-services-fitness:21.1.0") implementation("com.google.android.gms:play-services-auth:20.2.0") + implementation("androidx.fragment:fragment:1.6.1") // The new health connect api - implementation("androidx.health.connect:connect-client:1.1.0-alpha03") + implementation("androidx.health.connect:connect-client:1.1.0-alpha04") } diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index 39b3280d2..915de3639 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt @@ -10,6 +10,8 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Handler import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.NonNull import androidx.core.content.ContextCompat import androidx.health.connect.client.HealthConnectClient @@ -33,6 +35,7 @@ import com.google.android.gms.fitness.result.DataReadResponse import com.google.android.gms.fitness.result.SessionReadResponse import com.google.android.gms.tasks.OnFailureListener import com.google.android.gms.tasks.OnSuccessListener +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -47,6 +50,7 @@ import java.time.* import java.time.temporal.ChronoUnit import java.util.* import java.util.concurrent.* +import kotlin.reflect.KClass const val GOOGLE_FIT_PERMISSIONS_REQUEST_CODE = 1111 @@ -68,7 +72,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private var activity: Activity? = null private var context: Context? = null private var threadPoolExecutor: ExecutorService? = null - private var useHealthConnectIfAvailable: Boolean = false + private var useHealthConnectIfAvailable: Boolean = true private lateinit var healthConnectClient: HealthConnectClient private lateinit var scope: CoroutineScope @@ -91,6 +95,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private var BASAL_ENERGY_BURNED = "BASAL_ENERGY_BURNED" private var FLIGHTS_CLIMBED = "FLIGHTS_CLIMBED" private var RESPIRATORY_RATE = "RESPIRATORY_RATE" + private var HEART_RATE_VARIABILITY_SDNN = "HEART_RATE_VARIABILITY_SDNN" + private var LEAN_BODY_MASS = "LEAN_BODY_MASS" // TODO support unknown? private var SLEEP_ASLEEP = "SLEEP_ASLEEP" @@ -343,10 +349,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : context = flutterPluginBinding.applicationContext threadPoolExecutor = Executors.newFixedThreadPool(4) checkAvailability() - if (healthConnectAvailable) { - healthConnectClient = - HealthConnectClient.getOrCreate(flutterPluginBinding.applicationContext) - } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -1229,27 +1231,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } mResult = result - if (useHealthConnectIfAvailable && healthConnectAvailable) { - requestAuthorizationHC(call, result) - return - } - - val optionsToRegister = callToHealthTypes(call) - - // Set to false due to bug described in https://github.com/cph-cachet/flutter-plugins/issues/640#issuecomment-1366830132 - val isGranted = false - - // If not granted then ask for permission - if (!isGranted && activity != null) { - GoogleSignIn.requestPermissions( - activity!!, - GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) - } else { // / Permission already granted - result?.success(true) - } + requestAuthorizationHC(call, result) } /** @@ -1414,6 +1396,10 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } binding.addActivityResultListener(this) activity = binding.activity + + permissionsLauncher = (activity as ComponentActivity).registerForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted -> onPermissionsResult(granted) } } override fun onDetachedFromActivityForConfigChanges() { @@ -1440,14 +1426,17 @@ class HealthPlugin(private var channel: MethodChannel? = null) : fun checkAvailability(call: MethodCall? = null, result: Result? = null) { healthConnectStatus = HealthConnectClient.getSdkStatus(context!!) healthConnectAvailable = healthConnectStatus == HealthConnectClient.SDK_AVAILABLE + if (healthConnectAvailable) { + healthConnectClient = HealthConnectClient.getOrCreate(context!!) + } result?.success(healthConnectAvailable) } fun openSystemSettings(call: MethodCall, result: Result) { try { val intent = Intent() - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.action = HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK context?.startActivity(intent) result.success(true) } catch (e: Throwable) { @@ -1510,6 +1499,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + private lateinit var requestedPermissions: Set + private lateinit var permissionsLauncher: ActivityResultLauncher> + + private fun onPermissionsResult(granted: Set) { + mResult?.success(granted.containsAll(requestedPermissions)) + } + private fun requestAuthorizationHC(call: MethodCall, result: Result) { val args = call.arguments as HashMap<*, *> val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! @@ -1552,9 +1548,8 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } } - val contract = PermissionController.createRequestPermissionResultContract() - val intent = contract.createIntent(activity!!, permList.toSet()) - activity!!.startActivityForResult(intent, HEALTH_CONNECT_RESULT_CODE) + requestedPermissions = permList.toSet() + permissionsLauncher.launch(permList.toSet()) } fun getHCData(call: MethodCall, result: Result) { @@ -1810,6 +1805,24 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "source_name" to metadata.dataOrigin.packageName, ) ) + is HeartRateVariabilityRmssdRecord -> return listOf( + mapOf( + "value" to record.heartRateVariabilityMillis, + "date_from" to record.time.toEpochMilli(), + "date_to" to record.time.toEpochMilli(), + "source_id" to "", + "source_name" to metadata.dataOrigin.packageName, + ) + ) + is LeanBodyMassRecord -> return listOf( + mapOf( + "value" to record.mass.inKilograms, + "date_from" to record.time.toEpochMilli(), + "date_to" to record.time.toEpochMilli(), + "source_id" to "", + "source_name" to metadata.dataOrigin.packageName, + ) + ) // is ExerciseSessionRecord -> return listOf(mapOf("value" to , // "date_from" to , // "date_to" to , @@ -1967,6 +1980,16 @@ class HealthPlugin(private var channel: MethodChannel? = null) : rate = value, zoneOffset = null, ) + HEART_RATE_VARIABILITY_SDNN -> HeartRateVariabilityRmssdRecord( + time = Instant.ofEpochMilli(startTime), + heartRateVariabilityMillis = value, + zoneOffset = null, + ) + LEAN_BODY_MASS -> LeanBodyMassRecord( + time = Instant.ofEpochMilli(startTime), + mass = Mass.kilograms(value), + zoneOffset = null, + ) // AGGREGATE_STEP_COUNT -> StepsRecord() BLOOD_PRESSURE_SYSTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") BLOOD_PRESSURE_DIASTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") @@ -2106,6 +2129,13 @@ class HealthPlugin(private var channel: MethodChannel? = null) : 6 to SLEEP_REM, ) + fun sleepRecordTypeForOS(): KClass { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + SleepSessionRecord::class + else + SleepStageRecord::class + } + val MapToHCType = hashMapOf( BODY_FAT_PERCENTAGE to BodyFatRecord::class, HEIGHT to HeightRecord::class, @@ -2121,18 +2151,20 @@ class HealthPlugin(private var channel: MethodChannel? = null) : BLOOD_GLUCOSE to BloodGlucoseRecord::class, DISTANCE_DELTA to DistanceRecord::class, WATER to HydrationRecord::class, - SLEEP_ASLEEP to SleepStageRecord::class, - SLEEP_AWAKE to SleepStageRecord::class, - SLEEP_LIGHT to SleepStageRecord::class, - SLEEP_DEEP to SleepStageRecord::class, - SLEEP_REM to SleepStageRecord::class, - SLEEP_OUT_OF_BED to SleepStageRecord::class, + SLEEP_ASLEEP to sleepRecordTypeForOS(), + SLEEP_AWAKE to sleepRecordTypeForOS(), + SLEEP_LIGHT to sleepRecordTypeForOS(), + SLEEP_DEEP to sleepRecordTypeForOS(), + SLEEP_REM to sleepRecordTypeForOS(), + SLEEP_OUT_OF_BED to sleepRecordTypeForOS(), SLEEP_SESSION to SleepSessionRecord::class, WORKOUT to ExerciseSessionRecord::class, RESTING_HEART_RATE to RestingHeartRateRecord::class, BASAL_ENERGY_BURNED to BasalMetabolicRateRecord::class, FLIGHTS_CLIMBED to FloorsClimbedRecord::class, RESPIRATORY_RATE to RespiratoryRateRecord::class, + HEART_RATE_VARIABILITY_SDNN to HeartRateVariabilityRmssdRecord::class, + LEAN_BODY_MASS to LeanBodyMassRecord::class, // MOVE_MINUTES to TODO: Find alternative? // TODO: Implement remaining types // "ActiveCaloriesBurned" to ActiveCaloriesBurnedRecord::class, @@ -2152,7 +2184,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // "HeartRate" to HeartRateRecord::class, // "Height" to HeightRecord::class, // "Hydration" to HydrationRecord::class, - // "LeanBodyMass" to LeanBodyMassRecord::class, // "MenstruationFlow" to MenstruationFlowRecord::class, // "MenstruationPeriod" to MenstruationPeriodRecord::class, // "Nutrition" to NutritionRecord::class, diff --git a/packages/health/example/android/app/src/main/AndroidManifest.xml b/packages/health/example/android/app/src/main/AndroidManifest.xml index 59e0bebaa..893cb5cab 100644 --- a/packages/health/example/android/app/src/main/AndroidManifest.xml +++ b/packages/health/example/android/app/src/main/AndroidManifest.xml @@ -57,6 +57,10 @@ + + + + + + + + + + diff --git a/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt b/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt index d14f18281..720eb0f47 100644 --- a/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt +++ b/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt @@ -2,7 +2,7 @@ package cachet.plugins.example_app import android.os.Bundle -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity: FlutterActivity() { +class MainActivity: FlutterFragmentActivity() { } diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 7e570a2fd..0a687adc2 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -244,6 +244,22 @@ class _HealthAppState extends State { } } + Future openSettings() async { + try { + await health.openSystemSettings(); + } catch (error) { + print("Caught exception in openSettings: $error"); + } + } + + Future checkAvailability() async { + try { + await health.checkAvailability(); + } catch (error) { + print("Caught exception in checkAvailability: $error"); + } + } + Widget _contentFetchingData() { return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -412,6 +428,20 @@ class _HealthAppState extends State { style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: checkAvailability, + child: Text("Check availability", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), + TextButton( + onPressed: openAppSettings, + child: Text("Open settings", + style: TextStyle(color: Colors.white)), + style: ButtonStyle( + backgroundColor: + MaterialStatePropertyAll(Colors.blue))), ], ), Divider(thickness: 3), diff --git a/packages/health/ios/Classes/SwiftHealthPlugin.swift b/packages/health/ios/Classes/SwiftHealthPlugin.swift index 6d6568c9c..abecd4cd6 100644 --- a/packages/health/ios/Classes/SwiftHealthPlugin.swift +++ b/packages/health/ios/Classes/SwiftHealthPlugin.swift @@ -52,6 +52,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { let SLEEP_AWAKE = "SLEEP_AWAKE" let SLEEP_DEEP = "SLEEP_DEEP" let SLEEP_REM = "SLEEP_REM" + let LEAN_BODY_MASS = "LEAN_BODY_MASS" + let MOVE_TIME = "MOVE_TIME" + let STAND_HOUR = "STAND_HOUR" + let STAND_TIME = "STAND_TIME" let EXERCISE_TIME = "EXERCISE_TIME" let WORKOUT = "WORKOUT" @@ -237,6 +241,9 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { else { throw PluginError(message: "Invalid Arguments!") } + + // Sharing those data is not allowed. + let typesExcludedFromWrite = [MOVE_TIME, STAND_HOUR, STAND_TIME] var typesToRead = Set() var typesToWrite = Set() @@ -247,10 +254,10 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { case 0: typesToRead.insert(dataType) case 1: - typesToWrite.insert(dataType) + if !typesExcludedFromWrite.contains(key) { typesToWrite.insert(dataType) } default: typesToRead.insert(dataType) - typesToWrite.insert(dataType) + if !typesExcludedFromWrite.contains(key) { typesToWrite.insert(dataType) } } } @@ -915,12 +922,22 @@ public class SwiftHealthPlugin: NSObject, FlutterPlugin { dataTypesDict[SLEEP_AWAKE] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_DEEP] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! dataTypesDict[SLEEP_REM] = HKSampleType.categoryType(forIdentifier: .sleepAnalysis)! + dataTypesDict[LEAN_BODY_MASS] = HKSampleType.quantityType(forIdentifier: .leanBodyMass)! + + dataTypesDict[STAND_HOUR] = HKSampleType.categoryType(forIdentifier: .appleStandHour)! + dataTypesDict[STAND_TIME] = HKSampleType.quantityType(forIdentifier: .appleStandTime)! dataTypesDict[EXERCISE_TIME] = HKSampleType.quantityType(forIdentifier: .appleExerciseTime)! dataTypesDict[WORKOUT] = HKSampleType.workoutType() healthDataTypes = Array(dataTypesDict.values) } + + // Set up move time, requires iOS 14.5 + if #available(iOS 14.5, *) { + dataTypesDict[MOVE_TIME] = HKSampleType.quantityType(forIdentifier: .appleMoveTime)! + } + // Set up heart rate data types specific to the apple watch, requires iOS 12 if #available(iOS 12.2, *) { dataTypesDict[HIGH_HEART_RATE_EVENT] = HKSampleType.categoryType( diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index 0b7951d93..f67535def 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -29,7 +29,6 @@ enum HealthDataType { WEIGHT, DISTANCE_WALKING_RUNNING, FLIGHTS_CLIMBED, - MOVE_MINUTES, DISTANCE_DELTA, MINDFULNESS, WATER, @@ -48,6 +47,10 @@ enum HealthDataType { HEADACHE_MODERATE, HEADACHE_SEVERE, HEADACHE_UNSPECIFIED, + LEAN_BODY_MASS, + MOVE_TIME, + STAND_HOUR, + STAND_TIME, // Heart Rate events (specific to Apple Watch) HIGH_HEART_RATE_EVENT, @@ -112,6 +115,10 @@ const List _dataTypeKeysIOS = [ HealthDataType.HEADACHE_SEVERE, HealthDataType.HEADACHE_UNSPECIFIED, HealthDataType.ELECTROCARDIOGRAM, + HealthDataType.LEAN_BODY_MASS, + HealthDataType.MOVE_TIME, + HealthDataType.STAND_HOUR, + HealthDataType.STAND_TIME, ]; /// List of data types available on Android @@ -125,10 +132,10 @@ const List _dataTypeKeysAndroid = [ HealthDataType.BODY_MASS_INDEX, HealthDataType.BODY_TEMPERATURE, HealthDataType.HEART_RATE, + HealthDataType.HEART_RATE_VARIABILITY_SDNN, HealthDataType.HEIGHT, HealthDataType.STEPS, HealthDataType.WEIGHT, - HealthDataType.MOVE_MINUTES, HealthDataType.DISTANCE_DELTA, HealthDataType.SLEEP_AWAKE, HealthDataType.SLEEP_ASLEEP, @@ -143,6 +150,7 @@ const List _dataTypeKeysAndroid = [ HealthDataType.FLIGHTS_CLIMBED, HealthDataType.BASAL_ENERGY_BURNED, HealthDataType.RESPIRATORY_RATE, + HealthDataType.LEAN_BODY_MASS, ]; /// Maps a [HealthDataType] to a [HealthDataUnit]. @@ -174,8 +182,8 @@ const Map _dataTypeToUnit = { HealthDataType.WEIGHT: HealthDataUnit.KILOGRAM, HealthDataType.DISTANCE_WALKING_RUNNING: HealthDataUnit.METER, HealthDataType.FLIGHTS_CLIMBED: HealthDataUnit.COUNT, - HealthDataType.MOVE_MINUTES: HealthDataUnit.MINUTE, HealthDataType.DISTANCE_DELTA: HealthDataUnit.METER, + HealthDataType.LEAN_BODY_MASS: HealthDataUnit.KILOGRAM, HealthDataType.WATER: HealthDataUnit.LITER, HealthDataType.SLEEP_IN_BED: HealthDataUnit.MINUTE, @@ -197,6 +205,10 @@ const Map _dataTypeToUnit = { HealthDataType.HEADACHE_SEVERE: HealthDataUnit.MINUTE, HealthDataType.HEADACHE_UNSPECIFIED: HealthDataUnit.MINUTE, + HealthDataType.MOVE_TIME: HealthDataUnit.MINUTE, + HealthDataType.STAND_HOUR: HealthDataUnit.HOUR, + HealthDataType.STAND_TIME: HealthDataUnit.MINUTE, + // Heart Rate events (specific to Apple Watch) HealthDataType.HIGH_HEART_RATE_EVENT: HealthDataUnit.NO_UNIT, HealthDataType.LOW_HEART_RATE_EVENT: HealthDataUnit.NO_UNIT, @@ -463,8 +475,7 @@ enum ElectrocardiogramClassification { } /// Extension to assign numbers to [ElectrocardiogramClassification]s -extension ElectrocardiogramClassificationValue - on ElectrocardiogramClassification { +extension ElectrocardiogramClassificationValue on ElectrocardiogramClassification { int get value { switch (this) { case ElectrocardiogramClassification.NOT_SET: diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index bc4faa900..96ca74ca0 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -1,5 +1,18 @@ part of health; +extension _PlatformSpecificTypesExtension on List { + /// Filters out platform specific health data types. + List platformSpecific(PlatformType platformType) { + return this..removeWhere((type) => !_isTypeAvailable(type, platformType)); + } + + bool _isTypeAvailable(HealthDataType type, PlatformType platformType) { + return platformType == PlatformType.ANDROID + ? _dataTypeKeysAndroid.contains(type) + : _dataTypeKeysIOS.contains(type); + } +} + /// Main class for the Plugin. /// /// The plugin supports: @@ -108,9 +121,9 @@ class HealthFactory { } /// Opens native system settings for: - /// - Health on iOS + /// - Health on iOS /// - Health Connect on Android - /// + /// /// Throws if the application is not installed on the device. Future openSystemSettings() async { await _channel.invokeMethod('openSystemSettings'); @@ -158,7 +171,8 @@ class HealthFactory { } } - final mTypes = List.from(types, growable: true); + final mTypes = List.from(types, growable: true) + .platformSpecific(_platformType); final mPermissions = permissions == null ? List.filled(types.length, HealthDataAccess.READ.index, growable: true) @@ -433,7 +447,7 @@ class HealthFactory { DateTime startTime, DateTime endTime, List types) async { List dataPoints = []; - for (var type in types) { + for (var type in types.platformSpecific(_platformType)) { final result = await _prepareQuery(startTime, endTime, type); dataPoints.addAll(result); } diff --git a/packages/health/pubspec.yaml b/packages/health/pubspec.yaml index 28ce3128f..fc0f5b8f0 100644 --- a/packages/health/pubspec.yaml +++ b/packages/health/pubspec.yaml @@ -10,8 +10,8 @@ environment: dependencies: flutter: sdk: flutter - intl: ^0.18.0 - device_info_plus: ^9.0.0 + intl: ^0.18.1 + device_info_plus: ^9.0.3 dev_dependencies: flutter_test: