From 955f457bd7baf44776f76bd48c3209a824f8ca5e Mon Sep 17 00:00:00 2001 From: klnfreedom Date: Tue, 24 Jun 2025 19:24:07 +0300 Subject: [PATCH 1/6] Add rawWorkoutActivityType to WorkoutHealthValue for native data compatibility --- packages/health/lib/src/health_value_types.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index 6a8b28819..f128f7711 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -140,6 +140,9 @@ class WorkoutHealthValue extends HealthValue { /// Might not be available for all workouts. HealthDataUnit? totalStepsUnit; + /// Raw workoutActivityType from native data format. + String? rawWorkoutActivityType; + WorkoutHealthValue( {required this.workoutActivityType, this.totalEnergyBurned, @@ -147,11 +150,13 @@ class WorkoutHealthValue extends HealthValue { this.totalDistance, this.totalDistanceUnit, this.totalSteps, - this.totalStepsUnit}); + this.totalStepsUnit, + this.rawWorkoutActivityType}); /// Create a [WorkoutHealthValue] based on a health data point from native data format. factory WorkoutHealthValue.fromHealthDataPoint(dynamic dataPoint) => WorkoutHealthValue( + rawWorkoutActivityType: dataPoint['workoutActivityType'] as String?, workoutActivityType: HealthWorkoutActivityType.values.firstWhere( (element) => element.name == dataPoint['workoutActivityType'], orElse: () => HealthWorkoutActivityType.OTHER, From 212dd3e41390d0480b401b6700d09c59b0af509b Mon Sep 17 00:00:00 2001 From: klnfreedom Date: Tue, 1 Jul 2025 10:41:36 +0300 Subject: [PATCH 2/6] Add rawWorkoutActivityType to WorkoutHealthValue for improved data representation --- packages/health/lib/src/health_value_types.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index f128f7711..2f4822145 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -193,6 +193,7 @@ class WorkoutHealthValue extends HealthValue { @override String toString() => """$runtimeType - workoutActivityType: ${workoutActivityType.name}, + rawWorkoutActivityType: $rawWorkoutActivityType, totalEnergyBurned: $totalEnergyBurned, totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name}, totalDistance: $totalDistance, @@ -203,6 +204,7 @@ class WorkoutHealthValue extends HealthValue { @override bool operator ==(Object other) => other is WorkoutHealthValue && + rawWorkoutActivityType == other.rawWorkoutActivityType && workoutActivityType == other.workoutActivityType && totalEnergyBurned == other.totalEnergyBurned && totalEnergyBurnedUnit == other.totalEnergyBurnedUnit && @@ -214,6 +216,7 @@ class WorkoutHealthValue extends HealthValue { @override int get hashCode => Object.hash( workoutActivityType, + rawWorkoutActivityType, totalEnergyBurned, totalEnergyBurnedUnit, totalDistance, From c860aa2bba85de62f5c743460ebea629f1aa8ff7 Mon Sep 17 00:00:00 2001 From: Strime Date: Thu, 5 Jun 2025 08:48:36 +0400 Subject: [PATCH 3/6] [HEALTH] Distance data is always fetched for workouts regardless of need (cherry picked from commit 0cefe4c81e47f554fa22501c2899eba167a7e71d) --- .../cachet/plugins/health/HealthDataReader.kt | 59 ++++++++++++------- .../plugins/health/HealthPermissionChecker.kt | 39 ++++++++++++ 2 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index ba0db624c..53b60568b 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -29,6 +29,7 @@ class HealthDataReader( private val dataConverter: HealthDataConverter ) { private val recordingFilter = HealthRecordingFilter() + private val permissionChecker = HealthPermissionChecker(context) /** * Retrieves all health data points of a specified type within a given time range. @@ -306,33 +307,47 @@ class HealthDataReader( val record = rec as ExerciseSessionRecord // Get distance data - val distanceRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = DistanceRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) var totalDistance = 0.0 - for (distanceRec in distanceRequest.records) { - totalDistance += distanceRec.distance.inMeters + if (permissionChecker.isLocationPermissionGranted() && permissionChecker.isHealthDistancePermissionGranted()) { + val distanceRequest = healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = DistanceRecord::class, + timeRangeFilter = TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + for (distanceRec in distanceRequest.records) { + totalDistance += distanceRec.distance.inMeters + } + } else { + Log.i( + "FLUTTER_HEALTH", + "Skipping distance data retrieval for workout due to missing permissions (location or health distance)" + ) } // Get energy burned data - val energyBurnedRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = TotalCaloriesBurnedRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) var totalEnergyBurned = 0.0 - for (energyBurnedRec in energyBurnedRequest.records) { - totalEnergyBurned += energyBurnedRec.energy.inKilocalories + if (permissionChecker.isHealthTotalCaloriesBurnedPermissionGranted()) { + val energyBurnedRequest = healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = TotalCaloriesBurnedRecord::class, + timeRangeFilter = TimeRangeFilter.between( + record.startTime, + record.endTime, + ), + ), + ) + for (energyBurnedRec in energyBurnedRequest.records) { + totalEnergyBurned += energyBurnedRec.energy.inKilocalories + } + } else { + Log.i( + "FLUTTER_HEALTH", + "Skipping total calories burned data retrieval for workout due to missing permissions" + ) } // Get steps data diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt new file mode 100644 index 000000000..8b890e20c --- /dev/null +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt @@ -0,0 +1,39 @@ +package cachet.plugins.health + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +class HealthPermissionChecker(private val context: Context) { + + fun isLocationPermissionGranted(): Boolean { + val fineLocationGranted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + val coarseLocationGranted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + return fineLocationGranted || coarseLocationGranted + } + + fun isHealthDistancePermissionGranted(): Boolean { + val healthDistancePermission = "android.permission.health.READ_DISTANCE" + return ContextCompat.checkSelfPermission( + context, + healthDistancePermission + ) == PackageManager.PERMISSION_GRANTED + } + + fun isHealthTotalCaloriesBurnedPermissionGranted(): Boolean { + val healthCaloriesPermission = "android.permission.health.READ_TOTAL_CALORIES_BURNED" + return ContextCompat.checkSelfPermission( + context, + healthCaloriesPermission + ) == PackageManager.PERMISSION_GRANTED + } +} \ No newline at end of file From 32eb6be8fba565b66c331336ee404553c1669857 Mon Sep 17 00:00:00 2001 From: klnfreedom Date: Tue, 1 Jul 2025 11:57:35 +0300 Subject: [PATCH 4/6] Add health steps permission check and conditional data retrieval --- .../cachet/plugins/health/HealthDataReader.kt | 28 +++++++++++++------ .../plugins/health/HealthPermissionChecker.kt | 8 ++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index 53b60568b..283a62fb0 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -350,20 +350,30 @@ class HealthDataReader( ) } + // Get steps data - val stepRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = StepsRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime - ), - ), - ) var totalSteps = 0.0 + if (permissionChecker.isHealthStepsPermissionGranted()) { + val stepRequest = healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = StepsRecord::class, + timeRangeFilter = TimeRangeFilter.between( + record.startTime, + record.endTime + ), + ), + ) + for (stepRec in stepRequest.records) { totalSteps += stepRec.count } + + } else { + Log.i( + "FLUTTER_HEALTH", + "Skipping steps data retrieval for workout due to missing permissions" + ) + } // Add final datapoint healthConnectData.add( diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt index 8b890e20c..8388299be 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt @@ -36,4 +36,12 @@ class HealthPermissionChecker(private val context: Context) { healthCaloriesPermission ) == PackageManager.PERMISSION_GRANTED } + + fun isHealthStepsPermissionGranted(): Boolean { + val healthStepsPermission = "android.permission.health.READ_STEPS" + return ContextCompat.checkSelfPermission( + context, + healthStepsPermission + ) == PackageManager.PERMISSION_GRANTED + } } \ No newline at end of file From 3a2cc112669951227b0ab1df22dadb85d0d1b695 Mon Sep 17 00:00:00 2001 From: klnfreedom Date: Mon, 15 Sep 2025 13:12:10 +0300 Subject: [PATCH 5/6] Update error handling in HealthDataReader to provide more informative error responses --- .../src/main/kotlin/cachet/plugins/health/HealthDataReader.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index 283a62fb0..be26c5af6 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -106,7 +106,7 @@ class HealthDataReader( "Unable to return $dataType due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(null) + result.error("UNAVAILABLE", "Data not available", null) } } } From 347cbd8026f40b250c0d05e17b2eb18fe7e13b0b Mon Sep 17 00:00:00 2001 From: klnfreedom Date: Wed, 15 Oct 2025 06:57:54 +0300 Subject: [PATCH 6/6] Refactor AudiogramHealthValue and WorkoutHealthValue factories for improved readability --- .../health/lib/src/health_value_types.dart | 146 +++++++----------- 1 file changed, 57 insertions(+), 89 deletions(-) diff --git a/packages/health/lib/src/health_value_types.dart b/packages/health/lib/src/health_value_types.dart index 072c3630e..ed7360c02 100644 --- a/packages/health/lib/src/health_value_types.dart +++ b/packages/health/lib/src/health_value_types.dart @@ -71,16 +71,11 @@ class AudiogramHealthValue extends HealthValue { }); /// Create a [AudiogramHealthValue] based on a health data point from native data format. - factory AudiogramHealthValue.fromHealthDataPoint(dynamic dataPoint) => - AudiogramHealthValue( - frequencies: List.from(dataPoint['frequencies'] as List), - leftEarSensitivities: List.from( - dataPoint['leftEarSensitivities'] as List, - ), - rightEarSensitivities: List.from( - dataPoint['rightEarSensitivities'] as List, - ), - ); + factory AudiogramHealthValue.fromHealthDataPoint(dynamic dataPoint) => AudiogramHealthValue( + frequencies: List.from(dataPoint['frequencies'] as List), + leftEarSensitivities: List.from(dataPoint['leftEarSensitivities'] as List), + rightEarSensitivities: List.from(dataPoint['rightEarSensitivities'] as List), + ); @override String toString() => @@ -103,8 +98,7 @@ class AudiogramHealthValue extends HealthValue { listEquals(rightEarSensitivities, other.rightEarSensitivities); @override - int get hashCode => - Object.hash(frequencies, leftEarSensitivities, rightEarSensitivities); + int get hashCode => Object.hash(frequencies, leftEarSensitivities, rightEarSensitivities); } /// A [HealthValue] object for workouts @@ -147,50 +141,45 @@ class WorkoutHealthValue extends HealthValue { /// Raw workoutActivityType from native data format. String? rawWorkoutActivityType; - WorkoutHealthValue( - {required this.workoutActivityType, - this.totalEnergyBurned, - this.totalEnergyBurnedUnit, - this.totalDistance, - this.totalDistanceUnit, - this.totalSteps, - this.totalStepsUnit, - this.rawWorkoutActivityType, + WorkoutHealthValue({ + required this.workoutActivityType, + this.totalEnergyBurned, + this.totalEnergyBurnedUnit, + this.totalDistance, + this.totalDistanceUnit, + this.totalSteps, + this.totalStepsUnit, + this.rawWorkoutActivityType, }); /// Create a [WorkoutHealthValue] based on a health data point from native data format. - factory WorkoutHealthValue.fromHealthDataPoint(dynamic dataPoint) => - WorkoutHealthValue( - rawWorkoutActivityType: dataPoint['workoutActivityType'] as String?, - workoutActivityType: HealthWorkoutActivityType.values.firstWhere( - (element) => element.name == dataPoint['workoutActivityType'], - orElse: () => HealthWorkoutActivityType.OTHER, - ), - totalEnergyBurned: dataPoint['totalEnergyBurned'] != null - ? (dataPoint['totalEnergyBurned'] as num).toInt() - : null, - totalEnergyBurnedUnit: dataPoint['totalEnergyBurnedUnit'] != null - ? HealthDataUnit.values.firstWhere( - (element) => element.name == dataPoint['totalEnergyBurnedUnit'], - ) - : null, - totalDistance: dataPoint['totalDistance'] != null - ? (dataPoint['totalDistance'] as num).toInt() - : null, - totalDistanceUnit: dataPoint['totalDistanceUnit'] != null - ? HealthDataUnit.values.firstWhere( - (element) => element.name == dataPoint['totalDistanceUnit'], - ) - : null, - totalSteps: dataPoint['totalSteps'] != null - ? (dataPoint['totalSteps'] as num).toInt() - : null, - totalStepsUnit: dataPoint['totalStepsUnit'] != null - ? HealthDataUnit.values.firstWhere( - (element) => element.name == dataPoint['totalStepsUnit'], - ) - : null, - ); + factory WorkoutHealthValue.fromHealthDataPoint(dynamic dataPoint) => WorkoutHealthValue( + rawWorkoutActivityType: dataPoint['workoutActivityType'] as String?, + workoutActivityType: HealthWorkoutActivityType.values.firstWhere( + (element) => element.name == dataPoint['workoutActivityType'], + orElse: () => HealthWorkoutActivityType.OTHER, + ), + totalEnergyBurned: dataPoint['totalEnergyBurned'] != null + ? (dataPoint['totalEnergyBurned'] as num).toInt() + : null, + totalEnergyBurnedUnit: dataPoint['totalEnergyBurnedUnit'] != null + ? HealthDataUnit.values.firstWhere( + (element) => element.name == dataPoint['totalEnergyBurnedUnit'], + ) + : null, + totalDistance: dataPoint['totalDistance'] != null + ? (dataPoint['totalDistance'] as num).toInt() + : null, + totalDistanceUnit: dataPoint['totalDistanceUnit'] != null + ? HealthDataUnit.values.firstWhere( + (element) => element.name == dataPoint['totalDistanceUnit'], + ) + : null, + totalSteps: dataPoint['totalSteps'] != null ? (dataPoint['totalSteps'] as num).toInt() : null, + totalStepsUnit: dataPoint['totalStepsUnit'] != null + ? HealthDataUnit.values.firstWhere((element) => element.name == dataPoint['totalStepsUnit']) + : null, + ); @override Function get fromJsonFunction => _$WorkoutHealthValueFromJson; @@ -274,12 +263,7 @@ class ElectrocardiogramHealthValue extends HealthValue { factory ElectrocardiogramHealthValue.fromHealthDataPoint(dynamic dataPoint) => ElectrocardiogramHealthValue( voltageValues: (dataPoint['voltageValues'] as List) - .map( - (voltageValue) => - ElectrocardiogramVoltageValue.fromHealthDataPoint( - voltageValue, - ), - ) + .map((voltageValue) => ElectrocardiogramVoltageValue.fromHealthDataPoint(voltageValue)) .toList(), averageHeartRate: dataPoint['averageHeartRate'] as num?, samplingFrequency: dataPoint['samplingFrequency'] as double?, @@ -297,12 +281,8 @@ class ElectrocardiogramHealthValue extends HealthValue { classification == other.classification; @override - int get hashCode => Object.hash( - voltageValues, - averageHeartRate, - samplingFrequency, - classification, - ); + int get hashCode => + Object.hash(voltageValues, averageHeartRate, samplingFrequency, classification); @override String toString() => @@ -318,18 +298,14 @@ class ElectrocardiogramVoltageValue extends HealthValue { /// Time since the start of the ECG. num timeSinceSampleStart; - ElectrocardiogramVoltageValue({ - required this.voltage, - required this.timeSinceSampleStart, - }); + ElectrocardiogramVoltageValue({required this.voltage, required this.timeSinceSampleStart}); /// Create a [ElectrocardiogramVoltageValue] based on a health data point from native data format. - factory ElectrocardiogramVoltageValue.fromHealthDataPoint( - dynamic dataPoint, - ) => ElectrocardiogramVoltageValue( - voltage: dataPoint['voltage'] as num, - timeSinceSampleStart: dataPoint['timeSinceSampleStart'] as num, - ); + factory ElectrocardiogramVoltageValue.fromHealthDataPoint(dynamic dataPoint) => + ElectrocardiogramVoltageValue( + voltage: dataPoint['voltage'] as num, + timeSinceSampleStart: dataPoint['timeSinceSampleStart'] as num, + ); @override Function get fromJsonFunction => _$ElectrocardiogramVoltageValueFromJson; @@ -368,8 +344,7 @@ class InsulinDeliveryHealthValue extends HealthValue { final metadata = dataPoint['metadata'] == null ? null : Map.from(dataPoint['metadata'] as Map); - final reasonIndex = - metadata == null || !metadata.containsKey('HKInsulinDeliveryReason') + final reasonIndex = metadata == null || !metadata.containsKey('HKInsulinDeliveryReason') ? 0 : metadata['HKInsulinDeliveryReason'] as double; final reason = InsulinDeliveryReason.values[reasonIndex.toInt()]; @@ -386,9 +361,7 @@ class InsulinDeliveryHealthValue extends HealthValue { @override bool operator ==(Object other) => - other is InsulinDeliveryHealthValue && - units == other.units && - reason == other.reason; + other is InsulinDeliveryHealthValue && units == other.units && reason == other.reason; @override int get hashCode => Object.hash(units, reason); @@ -944,17 +917,13 @@ class MenstruationFlowHealthValue extends HealthValue { return MenstruationFlowHealthValue( flow: menstrualFlow, - isStartOfCycle: - dataPoint['metadata']?.containsKey('HKMenstrualCycleStart') == true + isStartOfCycle: dataPoint['metadata']?.containsKey('HKMenstrualCycleStart') == true ? dataPoint['metadata']['HKMenstrualCycleStart'] == 1.0 : null, - wasUserEntered: - dataPoint['metadata']?.containsKey('HKWasUserEntered') == true + wasUserEntered: dataPoint['metadata']?.containsKey('HKWasUserEntered') == true ? dataPoint['metadata']['HKWasUserEntered'] == 1.0 : null, - dateTime: DateTime.fromMillisecondsSinceEpoch( - dataPoint['date_from'] as int, - ), + dateTime: DateTime.fromMillisecondsSinceEpoch(dataPoint['date_from'] as int), ); } @@ -979,6 +948,5 @@ class MenstruationFlowHealthValue extends HealthValue { } @override - int get hashCode => - Object.hash(flow, isStartOfCycle, wasUserEntered, dateTime); + int get hashCode => Object.hash(flow, isStartOfCycle, wasUserEntered, dateTime); }