Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 77 additions & 43 deletions android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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(
Expand All @@ -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<Map<String, Any?>>()) // Return empty list instead of null
// Return empty list instead of null
result.error("UNAVAILABLE", "Data not available", emptyList<Map<String, Any?>>())
}
}
}

/**
* 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
*/
Expand Down Expand Up @@ -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
*/
Expand All @@ -202,7 +204,7 @@ class HealthDataReader(
val startTime = Instant.ofEpochMilli(call.argument<Long>("startTime")!!)
val endTime = Instant.ofEpochMilli(call.argument<Long>("endTime")!!)
val healthConnectData = mutableListOf<Map<String, Any?>>()

scope.launch {
try {
HealthConstants.mapToAggregateMetric[dataType]?.let { metricClassType ->
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand All @@ -283,15 +285,15 @@ 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
*/
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(
Expand All @@ -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<Int>,
start: Long,
end: Long,
recordingMethodsToFilter: List<Int>,
result: Result
) {
scope.launch {
Expand All @@ -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)"
Expand All @@ -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
Expand All @@ -387,7 +389,7 @@ class HealthDataReader(

for (rec in filteredRecords) {
val record = rec as ExerciseSessionRecord

// Get distance data
val distanceRequest = healthConnectClient.readRecords(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you forgot to remove this. You are requesting distance twice.

ReadRecordsRequest(
Expand All @@ -399,38 +401,70 @@ class HealthDataReader(
),
)
var totalDistance = 0.0
for (distanceRec in distanceRequest.records) {
totalDistance += distanceRec.distance.inMeters
if (permissionChecker.isLocationPermissionGranted() && permissionChecker.isHealthDistancePermissionGranted()) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need both permissions to obtain distance? From what I understand, you only need READ_DISTANCE to access this.
Even if having location permission means we can access the distance, then the condition should be OR instead of AND

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to return null for these values when we don't have permission. This way, we have an indication of whether we don't have access to this permission or if it's actually 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 8 additions & 0 deletions lib/src/health_value_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -146,6 +149,7 @@ class WorkoutHealthValue extends HealthValue {

WorkoutHealthValue({
required this.workoutActivityType,
this.rawWorkoutActivityType,
this.totalEnergyBurned,
this.totalEnergyBurnedUnit,
this.totalDistance,
Expand All @@ -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,
Expand Down Expand Up @@ -197,6 +202,7 @@ class WorkoutHealthValue extends HealthValue {
@override
String toString() =>
"""$runtimeType - workoutActivityType: ${workoutActivityType.name},
rawWorkoutActivityType: $rawWorkoutActivityType,
totalEnergyBurned: $totalEnergyBurned,
totalEnergyBurnedUnit: ${totalEnergyBurnedUnit?.name},
totalDistance: $totalDistance,
Expand All @@ -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 &&
Expand All @@ -218,6 +225,7 @@ class WorkoutHealthValue extends HealthValue {
@override
int get hashCode => Object.hash(
workoutActivityType,
rawWorkoutActivityType,
totalEnergyBurned,
totalEnergyBurnedUnit,
totalDistance,
Expand Down