From fd85060a9001e6131de19c2524d4b6de6bdb252c Mon Sep 17 00:00:00 2001 From: ojwalch Date: Sun, 12 Jul 2026 17:04:13 -0400 Subject: [PATCH 1/2] Convert Health Connect records page-by-page and add optional native downsampling Reading a dense heart-rate history previously materialized every raw record plus one map per sample in memory at once, OOM-crashing the app for heavy users. Non-workout, non-sleep types are now converted one page at a time so raw records are dropped as soon as each page is processed, and an optional samplingIntervalMillis argument keeps at most one sample per interval as the data is read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016ssFfNA35kFNFqSP8uCzyx --- .../cachet/plugins/health/HealthPlugin.kt | 66 ++++++++++++++++--- packages/health/lib/src/health_plugin.dart | 28 +++++--- 2 files changed, 78 insertions(+), 16 deletions(-) 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 17bd71869..205cbc81a 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 @@ -2966,19 +2966,75 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val dataType = call.argument("dataTypeKey")!! val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) + // Optional minimum spacing between returned samples, in milliseconds. + // When set, at most one converted data point is kept per interval. + // (The value fits in an Int for small intervals, so read it as Number.) + val samplingIntervalMillis = + call.argument("samplingIntervalMillis")?.toLong() val healthConnectData = mutableListOf>() scope.launch { try { MapToHCType[dataType]?.let { classType -> - val records = mutableListOf() - val granted = healthConnectClient.permissionController.getGrantedPermissions() if (!granted.contains(HealthPermission.getReadPermission(classType))) { Log.d("Health Plugin", "No Permission granted for $dataType") return@let } + // Workouts and sleep sessions have special conversion logic + // below that needs the full record objects; they are + // low-volume, so reading them all into memory is safe. + // Every other type is converted one page at a time so that + // dense histories (heart rate in particular can have a + // sample every few seconds) are never fully materialized + // as raw records + converted maps at once — doing so + // caused OutOfMemoryError crashes for heavy users. + if (dataType != WORKOUT && classType != SleepSessionRecord::class) { + // Timestamp (epoch millis) of the last data point kept + // by the sampling filter, across page boundaries. + var lastKeptMillis: Long? = null + var pageToken: String? = null + do { + val response = + healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = classType, + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + pageToken = pageToken, + ) + ) + for (rec in response.records) { + for (converted in convertRecord(rec, dataType)) { + if (samplingIntervalMillis != null) { + val dateFrom = + (converted["date_from"] as? Number)?.toLong() + if (dateFrom != null) { + val last = lastKeptMillis + if (last != null && + dateFrom - last < samplingIntervalMillis + ) { + // Too close to the previously + // kept sample — drop it. + continue + } + lastKeptMillis = dateFrom + } + } + healthConnectData.add(converted) + } + } + pageToken = response.pageToken + } while (!pageToken.isNullOrEmpty()) + return@let + } + + val records = mutableListOf() + // Set up the initial request to read health records with specified // parameters var request = @@ -3165,12 +3221,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } } - } else { - for (rec in records) { - healthConnectData.addAll( - convertRecord(rec, dataType) - ) - } } } diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index 93146b225..9e80b7a1d 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -622,17 +622,23 @@ class Health { } /// Fetch a list of health data points based on [types]. + /// + /// If [samplingInterval] is provided, at most one data point is returned + /// per interval, dropping the rest as the data is read. Use this for very + /// dense types (e.g. heart rate) where reading every raw sample can exhaust + /// memory. Currently only applied on Android (Health Connect). Future> getHealthDataFromTypes({ required List types, required DateTime startTime, required DateTime endTime, bool includeManualEntry = true, + Duration? samplingInterval, }) async { List dataPoints = []; for (var type in types) { - final result = - await _prepareQuery(startTime, endTime, type, includeManualEntry); + final result = await _prepareQuery( + startTime, endTime, type, includeManualEntry, samplingInterval); dataPoints.addAll(result); } @@ -684,8 +690,9 @@ class Health { DateTime startTime, DateTime endTime, HealthDataType dataType, - bool includeManualEntry, - ) async { + bool includeManualEntry, [ + Duration? samplingInterval, + ]) async { // Ask for device ID only once _deviceId ??= Platform.isAndroid ? (await _deviceInfo.androidInfo).id @@ -701,7 +708,8 @@ class Health { if (dataType == HealthDataType.BODY_MASS_INDEX && Platform.isAndroid) { return _computeAndroidBMI(startTime, endTime, includeManualEntry); } - return await _dataQuery(startTime, endTime, dataType, includeManualEntry); + return await _dataQuery( + startTime, endTime, dataType, includeManualEntry, samplingInterval); } /// Prepares an interval query, i.e. checks if the types are available, etc. @@ -750,14 +758,18 @@ class Health { } /// Fetches data points from Android/iOS native code. - Future> _dataQuery(DateTime startTime, DateTime endTime, - HealthDataType dataType, bool includeManualEntry) async { + Future> _dataQuery( + DateTime startTime, DateTime endTime, HealthDataType dataType, + bool includeManualEntry, + [Duration? samplingInterval]) async { final args = { 'dataTypeKey': dataType.name, 'dataUnitKey': dataTypeToUnit[dataType]!.name, 'startTime': startTime.millisecondsSinceEpoch, 'endTime': endTime.millisecondsSinceEpoch, - 'includeManualEntry': includeManualEntry + 'includeManualEntry': includeManualEntry, + if (samplingInterval != null) + 'samplingIntervalMillis': samplingInterval.inMilliseconds, }; final fetchedDataPoints = await _channel.invokeMethod('getData', args); From 7882f9ea0ea3fbfad21c4a8c0cbddeee50ca90b8 Mon Sep 17 00:00:00 2001 From: ojwalch Date: Tue, 14 Jul 2026 08:00:36 -0400 Subject: [PATCH 2/2] Aggregate heart rate as BPM_AVG per bucket; harden getAggregateHCData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interval queries for heart rate previously aggregated MEASUREMENTS_COUNT, which returns sample counts rather than heart rates, making the interval API unusable for HR. Mapping it to BPM_AVG lets callers read dense HR histories as bucketed averages computed inside Health Connect, so raw samples never cross IPC into the app — this is what makes heavy HR reads fast, not just memory-safe. Also in getAggregateHCData: skip buckets with no data instead of fabricating zero values (a 0 BPM sample is not a real data point), and add the same try/catch getHCData already has so provider errors return an empty result instead of crashing the coroutine. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016ssFfNA35kFNFqSP8uCzyx --- .../cachet/plugins/health/HealthPlugin.kt | 108 ++++++++++-------- 1 file changed, 58 insertions(+), 50 deletions(-) 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 205cbc81a..d67ccb6c8 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 @@ -3259,59 +3259,63 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) val healthConnectData = mutableListOf>() scope.launch { - MapToHCAggregateMetric[dataType]?.let { metricClassType -> - val request = - AggregateGroupByDurationRequest( - metrics = setOf(metricClassType), - timeRangeFilter = - TimeRangeFilter.between( - startTime, - endTime - ), - timeRangeSlicer = - Duration.ofSeconds( - interval + try { + MapToHCAggregateMetric[dataType]?.let { metricClassType -> + val request = + AggregateGroupByDurationRequest( + metrics = setOf(metricClassType), + timeRangeFilter = + TimeRangeFilter.between( + startTime, + endTime + ), + timeRangeSlicer = + Duration.ofSeconds( + interval + ) ) - ) - val response = healthConnectClient.aggregateGroupByDuration(request) - - for (durationResult in response) { - // The result may be null if no data is available in the - // time range - var totalValue = durationResult.result[metricClassType] - if (totalValue is Length) { - totalValue = totalValue.inMeters - } else if (totalValue is Energy) { - totalValue = totalValue.inKilocalories - } + val response = healthConnectClient.aggregateGroupByDuration(request) + + for (durationResult in response) { + // The result is null if no data is available in the + // bucket — skip it rather than fabricating a zero + // value (a 0 BPM heart rate is not a real data point). + var totalValue = durationResult.result[metricClassType] + ?: continue + if (totalValue is Length) { + totalValue = totalValue.inMeters + } else if (totalValue is Energy) { + totalValue = totalValue.inKilocalories + } - val packageNames = - durationResult.result.dataOrigins - .joinToString { origin -> - "${origin.packageName}" - } + val packageNames = + durationResult.result.dataOrigins + .joinToString { origin -> + "${origin.packageName}" + } - val data = - mapOf( - "value" to - (totalValue - ?: 0), - "date_from" to - durationResult.startTime - .toEpochMilli(), - "date_to" to - durationResult.endTime - .toEpochMilli(), - "source_name" to - packageNames, - "source_id" to "", - "is_manual_entry" to - packageNames.contains( - "user_input" - ) - ) - healthConnectData.add(data) + val data = + mapOf( + "value" to totalValue, + "date_from" to + durationResult.startTime + .toEpochMilli(), + "date_to" to + durationResult.endTime + .toEpochMilli(), + "source_name" to + packageNames, + "source_id" to "", + "is_manual_entry" to + packageNames.contains( + "user_input" + ) + ) + healthConnectData.add(data) + } } + } catch (e: Exception) { + Log.e("FLUTTER_HEALTH", e.toString()) } Handler(context!!.mainLooper).run { result.success(healthConnectData) } } @@ -4518,7 +4522,11 @@ class HealthPlugin(private var channel: MethodChannel? = null) : ACTIVE_ENERGY_BURNED to ActiveCaloriesBurnedRecord .ACTIVE_CALORIES_TOTAL, - HEART_RATE to HeartRateRecord.MEASUREMENTS_COUNT, + // BPM_AVG, not MEASUREMENTS_COUNT: interval queries for heart + // rate should return the average BPM per bucket, which lets + // callers read dense HR histories without ever materializing the + // raw samples (the aggregation runs inside Health Connect). + HEART_RATE to HeartRateRecord.BPM_AVG, DISTANCE_DELTA to DistanceRecord.DISTANCE_TOTAL, WATER to HydrationRecord.VOLUME_TOTAL, SLEEP_ASLEEP to SleepSessionRecord.SLEEP_DURATION_TOTAL,