diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index 37eab383..cedef8ee 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -30,12 +30,13 @@ 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. * Handles pagination for large datasets and applies recording method filtering. * Supports special processing for workout and sleep data. - * + * * @param call Method call containing 'dataTypeKey', 'startTime', 'endTime', 'recordingMethodsToFilter' * @param result Flutter result callback returning list of health data maps */ @@ -91,8 +92,8 @@ class HealthDataReader( // Handle special cases when (dataType) { WORKOUT -> handleWorkoutData(records, recordingMethodsToFilter, healthConnectData) - SLEEP_SESSION, SLEEP_ASLEEP, SLEEP_AWAKE, SLEEP_AWAKE_IN_BED, - SLEEP_LIGHT, SLEEP_DEEP, SLEEP_REM, SLEEP_OUT_OF_BED, SLEEP_UNKNOWN -> + SLEEP_SESSION, SLEEP_ASLEEP, SLEEP_AWAKE, SLEEP_AWAKE_IN_BED, + SLEEP_LIGHT, SLEEP_DEEP, SLEEP_REM, SLEEP_OUT_OF_BED, SLEEP_UNKNOWN -> handleSleepData(records, recordingMethodsToFilter, dataType, healthConnectData) else -> { val filteredRecords = recordingFilter.filterRecordsByRecordingMethods( @@ -114,14 +115,15 @@ class HealthDataReader( "Unable to return $dataType due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(emptyList>()) // Return empty list instead of null + // Return empty list instead of null + result.error("UNAVAILABLE", "Data not available", emptyList>()) } } } /** * Retrieves single health data point by given UUID and type. - * + * * @param call Method call containing 'UUID' and 'dataTypeKey' * @param result Flutter result callback returning list of health data maps */ @@ -192,7 +194,7 @@ class HealthDataReader( /** * Retrieves aggregated health data grouped by time intervals. * Calculates totals, averages, or counts over specified time periods. - * + * * @param call Method call containing 'dataTypeKey', 'interval', 'startTime', 'endTime' * @param result Flutter result callback returning list of aggregated data maps */ @@ -202,7 +204,7 @@ class HealthDataReader( val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val healthConnectData = mutableListOf>() - + scope.launch { try { HealthConstants.mapToAggregateMetric[dataType]?.let { metricClassType -> @@ -250,7 +252,7 @@ class HealthDataReader( /** * Retrieves interval-based health data. Currently delegates to getAggregateData. * Maintained for API compatibility and potential future differentiation. - * + * * @param call Method call with interval data parameters * @param result Flutter result callback returning interval data */ @@ -262,7 +264,7 @@ class HealthDataReader( * Gets total step count within a specified time interval with optional filtering. * Optimizes between aggregated queries and filtered individual record queries * based on whether recording method filtering is required. - * + * * @param call Method call containing 'startTime', 'endTime', 'recordingMethodsToFilter' * @param result Flutter result callback returning total step count as integer */ @@ -283,7 +285,7 @@ class HealthDataReader( /** * Retrieves aggregated step count using Health Connect's built-in aggregation. * Provides optimized step counting when no filtering is required. - * + * * @param start Start time in milliseconds * @param end End time in milliseconds * @param result Flutter result callback returning step count @@ -291,7 +293,7 @@ class HealthDataReader( private fun getAggregatedStepCount(start: Long, end: Long, result: Result) { val startInstant = Instant.ofEpochMilli(start) val endInstant = Instant.ofEpochMilli(end) - + scope.launch { try { val response = healthConnectClient.aggregate( @@ -318,16 +320,16 @@ class HealthDataReader( /** * Retrieves step count with recording method filtering applied. * Manually sums individual step records after applying specified filters. - * + * * @param start Start time in milliseconds * @param end End time in milliseconds * @param recordingMethodsToFilter List of recording methods to exclude * @param result Flutter result callback returning filtered step count */ private fun getStepCountFiltered( - start: Long, - end: Long, - recordingMethodsToFilter: List, + start: Long, + end: Long, + recordingMethodsToFilter: List, result: Result ) { scope.launch { @@ -345,7 +347,7 @@ class HealthDataReader( response.records ) val totalSteps = filteredRecords.sumOf { (it as StepsRecord).count.toInt() } - + Log.i( "FLUTTER_HEALTH::SUCCESS", "returning $totalSteps steps (excluding manual entries)" @@ -366,7 +368,7 @@ class HealthDataReader( * Handles special processing for workout/exercise session data. * Enriches workout records with associated distance, energy, and step data * by querying related records within the workout time period. - * + * * @param records List of ExerciseSessionRecord objects * @param recordingMethodsToFilter Recording methods to exclude (empty list means no filtering) * @param healthConnectData Mutable list to append processed workout data @@ -387,7 +389,7 @@ class HealthDataReader( for (rec in filteredRecords) { val record = rec as ExerciseSessionRecord - + // Get distance data val distanceRequest = healthConnectClient.readRecords( ReadRecordsRequest( @@ -399,38 +401,70 @@ class HealthDataReader( ), ) 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 - val stepRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = StepsRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime - ), - ), - ) var totalSteps = 0.0 - for (stepRec in stepRequest.records) { - totalSteps += stepRec.count + 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 @@ -462,7 +496,7 @@ class HealthDataReader( * Handles special processing for sleep session and stage data. * Processes sleep sessions and individual sleep stages based on requested data type. * Converts sleep stage enumerations to meaningful duration and type information. - * + * * @param records List of SleepSessionRecord objects * @param recordingMethodsToFilter Recording methods to exclude (empty list means no filtering) * @param dataType Specific sleep data type being requested diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt b/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt new file mode 100644 index 00000000..ee4fae3a --- /dev/null +++ b/android/src/main/kotlin/cachet/plugins/health/HealthPermissionChecker.kt @@ -0,0 +1,47 @@ +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 + } + + fun isHealthStepsPermissionGranted(): Boolean { + val healthStepsPermission = "android.permission.health.READ_STEPS" + return ContextCompat.checkSelfPermission( + context, + healthStepsPermission + ) == PackageManager.PERMISSION_GRANTED + } +} \ No newline at end of file diff --git a/lib/src/health_value_types.dart b/lib/src/health_value_types.dart index fd3b8c0a..29a7d0c1 100644 --- a/lib/src/health_value_types.dart +++ b/lib/src/health_value_types.dart @@ -120,6 +120,9 @@ class WorkoutHealthValue extends HealthValue { /// The type of the workout. HealthWorkoutActivityType workoutActivityType; + /// Raw workoutActivityType from native data format. + String? rawWorkoutActivityType; + /// The total energy burned during the workout. /// Might not be available for all workouts. int? totalEnergyBurned; @@ -146,6 +149,7 @@ class WorkoutHealthValue extends HealthValue { WorkoutHealthValue({ required this.workoutActivityType, + this.rawWorkoutActivityType, this.totalEnergyBurned, this.totalEnergyBurnedUnit, this.totalDistance, @@ -161,6 +165,7 @@ class WorkoutHealthValue extends HealthValue { (element) => element.name == dataPoint['workoutActivityType'], orElse: () => HealthWorkoutActivityType.OTHER, ), + rawWorkoutActivityType: dataPoint['workoutActivityType'] as String?, totalEnergyBurned: dataPoint['totalEnergyBurned'] != null ? (dataPoint['totalEnergyBurned'] as num).toInt() : null, @@ -197,6 +202,7 @@ class WorkoutHealthValue extends HealthValue { @override String toString() => """$runtimeType - workoutActivityType: ${workoutActivityType.name}, + rawWorkoutActivityType: $rawWorkoutActivityType, totalEnergyBurned: $totalEnergyBurned, totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name}, totalDistance: $totalDistance, @@ -208,6 +214,7 @@ class WorkoutHealthValue extends HealthValue { bool operator ==(Object other) => other is WorkoutHealthValue && workoutActivityType == other.workoutActivityType && + rawWorkoutActivityType == other.rawWorkoutActivityType && totalEnergyBurned == other.totalEnergyBurned && totalEnergyBurnedUnit == other.totalEnergyBurnedUnit && totalDistance == other.totalDistance && @@ -218,6 +225,7 @@ class WorkoutHealthValue extends HealthValue { @override int get hashCode => Object.hash( workoutActivityType, + rawWorkoutActivityType, totalEnergyBurned, totalEnergyBurnedUnit, totalDistance,