From 7a2de9eab06b6caf25b5e2be76cd4f83f06bdeb0 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Wed, 5 Aug 2026 12:55:11 +0200 Subject: [PATCH 01/19] fix(health): export one Health Connect sleep session --- android/app/build.gradle.kts | 3 + .../HealthConnectSleepWriter.kt | 111 ++++++++ .../openstrap_edge/NativeChannels.kt | 2 + lib/health/health_export.dart | 189 +++++++------ lib/health/health_sleep_session.dart | 166 ++++++++++++ test/health_sleep_export_test.dart | 250 ++++++++++++++++++ 6 files changed, 647 insertions(+), 74 deletions(-) create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt create mode 100644 lib/health/health_sleep_session.dart create mode 100644 test/health_sleep_export_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ae33004c..5db0937e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -130,6 +130,9 @@ flutter { dependencies { // Backs isCoreLibraryDesugaringEnabled (required by ota_update 7.x). coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + // Native sleep-session writer. Match health 11.1.1's AndroidX version so + // the app and plugin compile against one Health Connect API surface. + implementation("androidx.health.connect:connect-client:1.1.0-alpha07") // KeepAliveWorker (service watchdog). The workmanager Flutter plugin ships the // runtime transitively, but as an `implementation` dep it isn't visible to app // code at compile time — declare it explicitly for our native Worker. diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt new file mode 100644 index 00000000..72f42d23 --- /dev/null +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt @@ -0,0 +1,111 @@ +package wtf.openstrap.openstrap_edge + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.time.TimeRangeFilter +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Writes one Health Connect sleep parent containing all normalized stages. */ +object HealthConnectSleepWriter { + private const val CHANNEL = "openstrap/health_connect_sleep" + private const val REPLACE_SLEEP_SESSION = "replaceSleepSession" + private const val RECORDING_METHOD_AUTOMATIC = 2 + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val replaceMutex = Mutex() + + fun register(engine: FlutterEngine, context: Context) { + val app = context.applicationContext + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + if (call.method != REPLACE_SLEEP_SESSION) { + result.notImplemented() + return@setMethodCallHandler + } + scope.launch { + result.success(replace(app, call)) + } + } + } + + private suspend fun replace(context: Context, call: MethodCall): Boolean { + return replaceMutex.withLock { + try { + if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { + false + } else { + val session = buildSession(call) + if (session == null) { + false + } else { + val client = HealthConnectClient.getOrCreate(context) + + // This removes both records created by this writer and legacy + // one-stage fragments whose intervals fall inside the real + // overnight window, including the portion before midnight. + client.deleteRecords( + SleepSessionRecord::class, + TimeRangeFilter.between(session.startTime, session.endTime), + ) + client.insertRecords(listOf(session)).recordIdsList.size == 1 + } + } + } catch (_: Exception) { + false + } + } + } + + private fun buildSession(call: MethodCall): SleepSessionRecord? { + val start = call.argument("startTime")?.let(Instant::ofEpochMilli) ?: return null + val end = call.argument("endTime")?.let(Instant::ofEpochMilli) ?: return null + if (!start.isBefore(end)) return null + + val rawStages = call.argument>>("stages").orEmpty() + val stages = rawStages.mapNotNull(::buildStage).sortedBy { it.startTime } + var previousEnd = start + for (stage in stages) { + if (stage.startTime.isBefore(start) || stage.endTime.isAfter(end)) return null + if (!stage.startTime.isBefore(stage.endTime)) return null + if (stage.startTime.isBefore(previousEnd)) return null + previousEnd = stage.endTime + } + + val zoneRules = ZoneId.systemDefault().rules + return SleepSessionRecord( + startTime = start, + startZoneOffset = zoneRules.getOffset(start), + endTime = end, + endZoneOffset = zoneRules.getOffset(end), + title = "OpenStrap sleep", + stages = stages, + metadata = Metadata(recordingMethod = RECORDING_METHOD_AUTOMATIC), + ) + } + + private fun buildStage(raw: Map): SleepSessionRecord.Stage? { + val start = (raw["startTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) + ?: return null + val end = (raw["endTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) + ?: return null + val type = when (raw["stage"] as? String) { + "awake" -> SleepSessionRecord.STAGE_TYPE_AWAKE + "rem" -> SleepSessionRecord.STAGE_TYPE_REM + "light" -> SleepSessionRecord.STAGE_TYPE_LIGHT + "deep" -> SleepSessionRecord.STAGE_TYPE_DEEP + else -> return null + } + return SleepSessionRecord.Stage(start, end, type) + } +} diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt index ecbe0e45..b805ee3f 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -50,6 +50,8 @@ object NativeChannels { fun register(engine: FlutterEngine, context: Context) { val app = context.applicationContext + HealthConnectSleepWriter.register(engine, app) + MethodChannel(engine.dartExecutor.binaryMessenger, EDGE_TRACKING_CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 55eaf154..6a2ecf37 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -25,6 +25,7 @@ import 'package:flutter/foundation.dart'; import 'package:health/health.dart'; import '../data/db.dart'; +import 'health_sleep_session.dart'; /// What we can do with the health store right now. enum HealthLinkState { @@ -38,6 +39,8 @@ enum HealthLinkState { class HealthExporter { final _health = Health(); + final _androidSleep = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter()); bool _configured = false; /// True on iOS/macOS (Apple Health); false on Android (Health Connect). @@ -56,20 +59,20 @@ class HealthExporter { String get _hrvScalarKey => isApple ? 'sdnn' : 'rmssd'; List get _types => [ - HealthDataType.RESTING_HEART_RATE, - _hrvType, - HealthDataType.RESPIRATORY_RATE, - HealthDataType.HEART_RATE, - HealthDataType.ACTIVE_ENERGY_BURNED, - HealthDataType.BASAL_ENERGY_BURNED, - HealthDataType.STEPS, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, - HealthDataType.SLEEP_LIGHT, - HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_SESSION, - HealthDataType.WORKOUT, - ]; + HealthDataType.RESTING_HEART_RATE, + _hrvType, + HealthDataType.RESPIRATORY_RATE, + HealthDataType.HEART_RATE, + HealthDataType.ACTIVE_ENERGY_BURNED, + HealthDataType.BASAL_ENERGY_BURNED, + HealthDataType.STEPS, + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_LIGHT, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_SESSION, + HealthDataType.WORKOUT, + ]; // We do NOT gate on a write-permission check: HealthKit hides write-auth by // design, and Health Connect's hasPermissions(WRITE) frequently returns @@ -188,9 +191,8 @@ class HealthExporter { // convention: back off with growing spacing, and after _kMaxExportAttempts // give up on that specific day (log it, let the cursor advance past it) // rather than wedge the pipeline. Note this only matters for genuine - // thrown errors — an ungranted health permission doesn't throw (writes - // silently no-op, see the comment above _ensureConfigured), so it never - // enters this retry path or gets "given up on" by it. + // thrown errors or false results from the health-store APIs. A returned + // false is not success and must keep the day out of the exported prefix. static const _kRetryCursor = 'health_export_retry_state'; static const _kMaxExportAttempts = 6; static const _kRetryBackoff = [ @@ -342,7 +344,12 @@ class HealthExporter { // Health Connect only let an app delete its own data), then re-write fresh. for (final t in _types) { try { - await _health.delete(type: t, startTime: dayStart, endTime: dayEnd); + final deleted = await _health.delete( + type: t, + startTime: dayStart, + endTime: dayEnd, + ); + if (!deleted) success = false; } catch (e) { debugPrint('[health] delete ${t.name}: $e'); success = false; @@ -364,12 +371,14 @@ class HealthExporter { DateTime t) async { if (v == null || v <= 0) return; // absent input, not a failure try { - await _health.writeHealthData( - value: v.toDouble(), - type: type, - startTime: t, - endTime: t, - unit: unit); + final wrote = await _health.writeHealthData( + value: v.toDouble(), + type: type, + startTime: t, + endTime: t, + unit: unit, + ); + if (!wrote) success = false; } catch (e) { debugPrint('[health] write ${type.name}: $e'); success = false; @@ -425,12 +434,14 @@ class HealthExporter { final calPerHour = cal / bucketCount; for (int i = 0; i < bucketCount; i++) { try { - await _health.writeHealthData( - value: calPerHour, - type: HealthDataType.ACTIVE_ENERGY_BURNED, - startTime: bucketBounds[i], - endTime: bucketBounds[i + 1], - unit: HealthDataUnit.KILOCALORIE); + final wrote = await _health.writeHealthData( + value: calPerHour, + type: HealthDataType.ACTIVE_ENERGY_BURNED, + startTime: bucketBounds[i], + endTime: bucketBounds[i + 1], + unit: HealthDataUnit.KILOCALORIE, + ); + if (!wrote) success = false; } catch (e) { debugPrint('[health] write energy bucket $i: $e'); success = false; @@ -446,12 +457,14 @@ class HealthExporter { final basalPerHour = basal / bucketCount; for (int i = 0; i < bucketCount; i++) { try { - await _health.writeHealthData( - value: basalPerHour, - type: HealthDataType.BASAL_ENERGY_BURNED, - startTime: bucketBounds[i], - endTime: bucketBounds[i + 1], - unit: HealthDataUnit.KILOCALORIE); + final wrote = await _health.writeHealthData( + value: basalPerHour, + type: HealthDataType.BASAL_ENERGY_BURNED, + startTime: bucketBounds[i], + endTime: bucketBounds[i + 1], + unit: HealthDataUnit.KILOCALORIE, + ); + if (!wrote) success = false; } catch (e) { debugPrint('[health] write basal energy bucket $i: $e'); success = false; @@ -467,10 +480,10 @@ class HealthExporter { final endTs = dayEnd.millisecondsSinceEpoch ~/ 1000; // Group by minute to downsample hrRows = await db.rawQuery( - 'SELECT (rec_ts / 60) * 60 AS minute_ts, AVG(hr) as avg_hr ' - 'FROM decoded_onehz ' - 'WHERE rec_ts >= ? AND rec_ts < ? AND hr > 0 ' - 'GROUP BY minute_ts', + 'SELECT (rec_ts / 60) * 60 AS minute_ts, AVG(hr) as avg_hr ' + 'FROM decoded_onehz ' + 'WHERE rec_ts >= ? AND rec_ts < ? AND hr > 0 ' + 'GROUP BY minute_ts', [startTs, endTs]); } catch (e) { debugPrint('[health] query continuous hr: $e'); @@ -483,12 +496,14 @@ class HealthExporter { if (avgHr > 0) { final t = DateTime.fromMillisecondsSinceEpoch(minuteTs * 1000); try { - await _health.writeHealthData( - value: avgHr, - type: HealthDataType.HEART_RATE, - startTime: t, - endTime: t.add(const Duration(minutes: 1)), - unit: HealthDataUnit.BEATS_PER_MINUTE); + final wrote = await _health.writeHealthData( + value: avgHr, + type: HealthDataType.HEART_RATE, + startTime: t, + endTime: t.add(const Duration(minutes: 1)), + unit: HealthDataUnit.BEATS_PER_MINUTE, + ); + if (!wrote) success = false; } catch (e) { debugPrint('[health] write continuous hr @$minuteTs: $e'); success = false; @@ -501,37 +516,53 @@ class HealthExporter { final steps = sc('steps'); if (steps != null && steps > 0) { try { - await _health.writeHealthData( - value: steps.toDouble(), - type: HealthDataType.STEPS, - startTime: dayStart, - endTime: dayEnd, - unit: HealthDataUnit.COUNT); + final wrote = await _health.writeHealthData( + value: steps.toDouble(), + type: HealthDataType.STEPS, + startTime: dayStart, + endTime: dayEnd, + unit: HealthDataUnit.COUNT, + ); + if (!wrote) success = false; } catch (e) { debugPrint('[health] write steps: $e'); success = false; } } - // Sleep stages from the per-segment hypnogram (real time ranges). - final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const []; - for (final s in segs) { - if (s is! Map) continue; - final st = (s['start'] as num?)?.toInt(); - final en = (s['end'] as num?)?.toInt(); - final stage = s['stage']?.toString(); - if (st == null || en == null || en <= st || stage == null) continue; - final type = _sleepType(stage); - if (type == null) continue; + // Health Connect models stages as children of ONE SleepSessionRecord. The + // health 11.1.1 generic SLEEP_* writer instead creates one parent record + // per call, fragmenting a night. Android therefore uses our typed native + // replace API; Apple Health keeps its existing per-stage samples. + if (Platform.isAndroid) { try { - await _health.writeHealthData( + if (!await _androidSleep.replace(b)) success = false; + } catch (e) { + debugPrint('[health] write Android sleep session: $e'); + success = false; + } + } else { + final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const []; + for (final s in segs) { + if (s is! Map) continue; + final st = (s['start'] as num?)?.toInt(); + final en = (s['end'] as num?)?.toInt(); + final stage = s['stage']?.toString(); + if (st == null || en == null || en <= st || stage == null) continue; + final type = _sleepType(stage); + if (type == null) continue; + try { + final wrote = await _health.writeHealthData( value: 0, type: type, startTime: DateTime.fromMillisecondsSinceEpoch(st * 1000), - endTime: DateTime.fromMillisecondsSinceEpoch(en * 1000)); - } catch (e) { - debugPrint('[health] write sleep ${type.name}: $e'); - success = false; + endTime: DateTime.fromMillisecondsSinceEpoch(en * 1000), + ); + if (!wrote) success = false; + } catch (e) { + debugPrint('[health] write sleep ${type.name}: $e'); + success = false; + } } } @@ -569,18 +600,21 @@ class HealthExporter { /// eventually "giving up" on — and silently pausing — that WHOLE day's real /// health export (RHR/HRV/steps/sleep), not just the still-live workout. Future _writeOneWorkout(Map r) async { - if ((r['status']?.toString() ?? '') == 'live') return null; // skip, not a failure + if ((r['status']?.toString() ?? '') == 'live') { + return null; // skip, not a failure + } final st = (r['start_ts'] as num?)?.toInt(); final en = (r['end_ts'] as num?)?.toInt(); - if (st == null || en == null || en <= st) return null; // skip, not a failure + if (st == null || en == null || en <= st) { + return null; // skip, not a failure + } try { - await _health.writeWorkoutData( + return await _health.writeWorkoutData( activityType: _activity(r['type']?.toString()), start: DateTime.fromMillisecondsSinceEpoch(st * 1000), end: DateTime.fromMillisecondsSinceEpoch(en * 1000), totalEnergyBurned: (r['calories'] as num?)?.round(), ); - return true; } catch (e) { debugPrint('[health] write workout @$st: $e'); return false; @@ -615,13 +649,20 @@ class HealthExporter { if (await _androidUnavailable() != null) return false; final start = DateTime.fromMillisecondsSinceEpoch(st * 1000); final end = DateTime.fromMillisecondsSinceEpoch(en * 1000); + var success = true; try { - await _health.delete( - type: HealthDataType.WORKOUT, startTime: start, endTime: end); + final deleted = await _health.delete( + type: HealthDataType.WORKOUT, + startTime: start, + endTime: end, + ); + if (!deleted) success = false; } catch (e) { debugPrint('[health] delete workout @$st: $e'); + success = false; } - return (await _writeOneWorkout(session)) ?? false; + final wrote = (await _writeOneWorkout(session)) ?? false; + return success && wrote; } catch (e) { debugPrint('[health] exportWorkout: $e'); return false; diff --git a/lib/health/health_sleep_session.dart b/lib/health/health_sleep_session.dart new file mode 100644 index 00000000..74911869 --- /dev/null +++ b/lib/health/health_sleep_session.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +enum HealthSleepStage { awake, rem, light, deep } + +class HealthSleepStageInterval { + const HealthSleepStageInterval({ + required this.start, + required this.end, + required this.stage, + }); + + final DateTime start; + final DateTime end; + final HealthSleepStage stage; + + Duration get duration => end.difference(start); + + Map toMap() => { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + 'stage': stage.name, + }; +} + +class HealthSleepSession { + const HealthSleepSession({ + required this.start, + required this.end, + required this.stages, + }); + + final DateTime start; + final DateTime end; + final List stages; + + Map toMap() => { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + 'stages': stages.map((stage) => stage.toMap()).toList(), + }; +} + +HealthSleepSession? normalizeHealthSleepSession(Map bundle) { + final sleep = bundle['sleep']; + final window = sleep is Map ? sleep['window'] : null; + final value = window is Map ? window['value'] : null; + final onsetMs = value is Map ? (value['onset_ms'] as num?)?.toInt() : null; + final offsetMs = value is Map ? (value['offset_ms'] as num?)?.toInt() : null; + if (onsetMs == null || offsetMs == null || offsetMs <= onsetMs) return null; + + final start = DateTime.fromMillisecondsSinceEpoch(onsetMs); + final end = DateTime.fromMillisecondsSinceEpoch(offsetMs); + final series = bundle['series']; + final rawStages = series is Map ? series['hypnogram'] : null; + final candidates = []; + if (rawStages is List) { + for (final raw in rawStages) { + if (raw is! Map) continue; + final startSeconds = (raw['start'] as num?)?.toInt(); + final endSeconds = (raw['end'] as num?)?.toInt(); + final stage = _stageOf(raw['stage']?.toString()); + if (startSeconds == null || endSeconds == null || stage == null) continue; + + final rawStart = DateTime.fromMillisecondsSinceEpoch(startSeconds * 1000); + final rawEnd = DateTime.fromMillisecondsSinceEpoch(endSeconds * 1000); + final clippedStart = rawStart.isBefore(start) ? start : rawStart; + final clippedEnd = rawEnd.isAfter(end) ? end : rawEnd; + if (!clippedStart.isBefore(clippedEnd)) continue; + candidates.add( + HealthSleepStageInterval( + start: clippedStart, + end: clippedEnd, + stage: stage, + ), + ); + } + } + + candidates.sort((a, b) { + final byStart = a.start.compareTo(b.start); + return byStart != 0 ? byStart : a.end.compareTo(b.end); + }); + + final normalized = []; + var cursor = start; + for (final candidate in candidates) { + final normalizedStart = candidate.start.isBefore(cursor) + ? cursor + : candidate.start; + if (!normalizedStart.isBefore(candidate.end)) continue; + normalized.add( + HealthSleepStageInterval( + start: normalizedStart, + end: candidate.end, + stage: candidate.stage, + ), + ); + cursor = candidate.end; + } + + return HealthSleepSession(start: start, end: end, stages: normalized); +} + +HealthSleepStage? _stageOf(String? stage) { + switch (stage) { + case 'wake': + case 'awake': + return HealthSleepStage.awake; + case 'rem': + return HealthSleepStage.rem; + case 'light': + case 'nrem': + return HealthSleepStage.light; + case 'deep': + return HealthSleepStage.deep; + default: + return null; + } +} + +abstract interface class HealthConnectSleepSessionWriter { + Future replace(HealthSleepSession session); +} + +class MethodChannelHealthConnectSleepSessionWriter + implements HealthConnectSleepSessionWriter { + MethodChannelHealthConnectSleepSessionWriter({ + this.channel = const MethodChannel('openstrap/health_connect_sleep'), + }); + + final MethodChannel channel; + Future _pending = Future.value(); + + @override + Future replace(HealthSleepSession session) { + final result = Completer(); + _pending = _pending.then((_) async { + try { + result.complete( + await channel.invokeMethod( + 'replaceSleepSession', + session.toMap(), + ) == + true, + ); + } catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; + } +} + +class HealthConnectSleepSessionExporter { + const HealthConnectSleepSessionExporter({required this.writer}); + + final HealthConnectSleepSessionWriter writer; + + Future replace(Map bundle) async { + final session = normalizeHealthSleepSession(bundle); + if (session == null) return true; + return writer.replace(session); + } +} diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart new file mode 100644 index 00000000..bc746d2a --- /dev/null +++ b/test/health_sleep_export_test.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/health/health_sleep_session.dart'; + +int _seconds(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; + +Map _segment(DateTime start, DateTime end, String stage) => { + 'start': _seconds(start), + 'end': _seconds(end), + 'stage': stage, +}; + +Map _overnightBundle() { + final start = DateTime(2026, 8, 4, 23, 55); + final end = DateTime(2026, 8, 5, 7, 46); + final at0011 = DateTime(2026, 8, 5, 0, 11); + final at0241 = DateTime(2026, 8, 5, 2, 41); + final at0257 = DateTime(2026, 8, 5, 2, 57); + final at0545 = DateTime(2026, 8, 5, 5, 45); + final at0720 = DateTime(2026, 8, 5, 7, 20); + final at0736 = DateTime(2026, 8, 5, 7, 36); + + return { + 'sleep': { + 'window': { + 'value': { + 'onset_ms': start.millisecondsSinceEpoch, + 'offset_ms': end.millisecondsSinceEpoch, + }, + }, + }, + 'series': { + // Intentionally out of order: normalization must use timestamps, not + // the input list order. + 'hypnogram': [ + _segment(at0545, at0720, 'rem'), // 95 min + _segment(start, at0011, 'wake'), // 16 min awake + _segment(at0257, at0545, 'light'), // 168 min + _segment(at0720, at0736, 'awake'), // 16 min awake + _segment(at0241, at0257, 'deep'), // 16 min + _segment(at0011, at0241, 'light'), // 150 min + ], + }, + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Health Connect sleep-session export regression', () { + test('normalizes one complete cross-midnight session with every stage', () { + final session = normalizeHealthSleepSession(_overnightBundle()); + + expect(session, isNotNull); + expect(session!.start, DateTime(2026, 8, 4, 23, 55)); + expect(session.end, DateTime(2026, 8, 5, 7, 46)); + expect(session.stages, hasLength(6)); + + for (var i = 0; i < session.stages.length; i++) { + final stage = session.stages[i]; + expect(stage.start.isBefore(stage.end), isTrue); + expect(stage.start.isBefore(session.start), isFalse); + expect(stage.end.isAfter(session.end), isFalse); + if (i > 0) { + expect( + stage.start.isBefore(session.stages[i - 1].end), + isFalse, + reason: 'sleep stages must be ordered and non-overlapping', + ); + } + } + + final minutesByStage = {}; + for (final stage in session.stages) { + minutesByStage.update( + stage.stage, + (value) => value + stage.duration.inMinutes, + ifAbsent: () => stage.duration.inMinutes, + ); + } + expect(minutesByStage, { + HealthSleepStage.awake: 32, + HealthSleepStage.rem: 95, + HealthSleepStage.light: 318, + HealthSleepStage.deep: 16, + }); + }); + + test( + 'clips stages to the parent and removes overlap and zero duration', + () { + final start = DateTime(2026, 8, 4, 23, 55); + final end = DateTime(2026, 8, 5, 0, 25); + final bundle = { + 'sleep': { + 'window': { + 'value': { + 'onset_ms': start.millisecondsSinceEpoch, + 'offset_ms': end.millisecondsSinceEpoch, + }, + }, + }, + 'series': { + 'hypnogram': [ + _segment( + start.subtract(const Duration(minutes: 5)), + start.add(const Duration(minutes: 10)), + 'light', + ), + _segment( + start.add(const Duration(minutes: 8)), + start.add(const Duration(minutes: 20)), + 'deep', + ), + _segment(end, end, 'rem'), + _segment( + start.add(const Duration(minutes: 20)), + end.add(const Duration(minutes: 5)), + 'rem', + ), + ], + }, + }; + + final session = normalizeHealthSleepSession(bundle)!; + + expect(session.stages, hasLength(3)); + expect(session.stages[0].start, start); + expect(session.stages[0].end, start.add(const Duration(minutes: 10))); + expect(session.stages[1].start, start.add(const Duration(minutes: 10))); + expect(session.stages[1].end, start.add(const Duration(minutes: 20))); + expect(session.stages[2].start, start.add(const Duration(minutes: 20))); + expect(session.stages[2].end, end); + }, + ); + + test('one channel call carries one parent and every stage', () async { + const channel = MethodChannel('openstrap/test_health_connect_sleep'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter(channel: channel), + ); + + expect(await exporter.replace(_overnightBundle()), isTrue); + expect(calls, hasLength(1)); + expect(calls.single.method, 'replaceSleepSession'); + final args = (calls.single.arguments as Map).cast(); + expect( + args['startTime'], + DateTime(2026, 8, 4, 23, 55).millisecondsSinceEpoch, + ); + expect( + args['endTime'], + DateTime(2026, 8, 5, 7, 46).millisecondsSinceEpoch, + ); + expect(args['stages'] as List, hasLength(6)); + }); + + test( + 're-export uses the replace operation and a false result propagates', + () async { + const channel = MethodChannel('openstrap/test_health_connect_replace'); + final storedParents = >[]; + var writes = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + writes++; + final args = (call.arguments as Map).cast(); + storedParents + ..clear() + ..add(args); + return writes == 1; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter( + channel: channel, + ), + ); + + expect(await exporter.replace(_overnightBundle()), isTrue); + expect(await exporter.replace(_overnightBundle()), isFalse); + expect(storedParents, hasLength(1)); + expect(storedParents.single['stages'] as List, hasLength(6)); + }, + ); + + test( + 'overlapping exports never enter the native replace concurrently', + () async { + const channel = MethodChannel( + 'openstrap/test_health_connect_concurrency', + ); + final firstEntered = Completer(); + final releaseFirst = Completer(); + var calls = 0; + var activeCalls = 0; + var maxActiveCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + activeCalls++; + if (activeCalls > maxActiveCalls) maxActiveCalls = activeCalls; + if (calls == 1) { + firstEntered.complete(); + await releaseFirst.future; + } + activeCalls--; + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter( + channel: channel, + ), + ); + + final first = exporter.replace(_overnightBundle()); + await firstEntered.future; + final second = exporter.replace(_overnightBundle()); + await Future.delayed(Duration.zero); + + expect(calls, 1, reason: 'the second native replace must stay queued'); + releaseFirst.complete(true); + expect(await first, isTrue); + expect(await second, isTrue); + expect(calls, 2); + expect(maxActiveCalls, 1); + }, + ); + }); +} From 65dec51aeb0c5868ed1b6347d1860914cf0c4cec Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Wed, 5 Aug 2026 13:56:22 +0200 Subject: [PATCH 02/19] fix(health): make Health Connect retries reliable --- .../HealthConnectSleepWriter.kt | 5 +- lib/health/health_export.dart | 140 ++++++++++++++---- lib/state/app_state.dart | 2 +- test/health_sleep_export_test.dart | 47 ++++++ 4 files changed, 162 insertions(+), 32 deletions(-) diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt index 72f42d23..ed93c829 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt @@ -1,6 +1,7 @@ package wtf.openstrap.openstrap_edge import android.content.Context +import android.util.Log import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.records.SleepSessionRecord import androidx.health.connect.client.records.metadata.Metadata @@ -19,6 +20,7 @@ import kotlinx.coroutines.sync.withLock /** Writes one Health Connect sleep parent containing all normalized stages. */ object HealthConnectSleepWriter { + private const val TAG = "OpenStrapSleepExport" private const val CHANNEL = "openstrap/health_connect_sleep" private const val REPLACE_SLEEP_SESSION = "replaceSleepSession" private const val RECORDING_METHOD_AUTOMATIC = 2 @@ -61,7 +63,8 @@ object HealthConnectSleepWriter { client.insertRecords(listOf(session)).recordIdsList.size == 1 } } - } catch (_: Exception) { + } catch (error: Exception) { + Log.e(TAG, "SleepSessionRecord replace failed", error) false } } diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 6a2ecf37..96e4bd31 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -37,10 +37,51 @@ enum HealthLinkState { unsupported, // no health store on this device (iPad / simulator) } +const _sleepHealthTypes = { + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_LIGHT, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_SESSION, +}; + +List healthDeleteTypes({required bool isApplePlatform}) { + final types = [ + HealthDataType.RESTING_HEART_RATE, + isApplePlatform + ? HealthDataType.HEART_RATE_VARIABILITY_SDNN + : HealthDataType.HEART_RATE_VARIABILITY_RMSSD, + HealthDataType.RESPIRATORY_RATE, + HealthDataType.HEART_RATE, + HealthDataType.ACTIVE_ENERGY_BURNED, + HealthDataType.BASAL_ENERGY_BURNED, + HealthDataType.STEPS, + ..._sleepHealthTypes, + HealthDataType.WORKOUT, + ]; + return isApplePlatform + ? types + : types.where((type) => !_sleepHealthTypes.contains(type)).toList(); +} + +bool shouldAttemptHealthExport({ + required int attempts, + required int maxAttempts, + required DateTime now, + required DateTime? lastAttempt, + required Duration backoff, + bool force = false, +}) { + if (force) return true; + if (attempts >= maxAttempts) return false; + return lastAttempt == null || now.difference(lastAttempt) >= backoff; +} + class HealthExporter { final _health = Health(); final _androidSleep = HealthConnectSleepSessionExporter( - writer: MethodChannelHealthConnectSleepSessionWriter()); + writer: MethodChannelHealthConnectSleepSessionWriter(), + ); bool _configured = false; /// True on iOS/macOS (Apple Health); false on Android (Health Connect). @@ -128,8 +169,10 @@ class HealthExporter { final un = await _androidUnavailable(); if (un != null) return un; try { - await _health.requestAuthorization(_types, - permissions: _types.map((_) => HealthDataAccess.WRITE).toList()); + await _health.requestAuthorization( + _types, + permissions: _types.map((_) => HealthDataAccess.WRITE).toList(), + ); } catch (e) { debugPrint('[health] requestAuthorization: $e'); } @@ -217,7 +260,11 @@ class HealthExporter { } } - Future exportAll({bool reset = false, void Function(int days)? onProgress}) async { + Future exportAll({ + bool reset = false, + bool forceRetry = false, + void Function(int days)? onProgress, + }) async { await _ensureConfigured(); if (await _androidUnavailable() != null) return 0; // HC missing/outdated try { @@ -264,10 +311,19 @@ class HealthExporter { var ok = false; var giveUp = false; - if (attempts >= _kMaxExportAttempts) { + final shouldAttempt = shouldAttemptHealthExport( + attempts: attempts, + maxAttempts: _kMaxExportAttempts, + now: DateTime.fromMillisecondsSinceEpoch(nowMs), + lastAttempt: lastAttemptMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), + backoff: _backoffFor(attempts), + force: forceRetry, + ); + if (!shouldAttempt && attempts >= _kMaxExportAttempts) { giveUp = true; - } else if (lastAttemptMs != null && - nowMs - lastAttemptMs < _backoffFor(attempts).inMilliseconds) { + } else if (!shouldAttempt) { // Not due for retry yet — don't hammer the health store on every // drain/derive pass; counts as "not done" for the cursor below. } else { @@ -286,10 +342,12 @@ class HealthExporter { }; retryStateDirty = true; debugPrint( - '[health] day $date export incomplete (attempt $nextAttempts/$_kMaxExportAttempts)'); + '[health] day $date export incomplete (attempt $nextAttempts/$_kMaxExportAttempts)', + ); if (nextAttempts >= _kMaxExportAttempts) { debugPrint( - '[health] day $date exceeded $_kMaxExportAttempts export attempts — giving up, will stop blocking newer days'); + '[health] day $date exceeded $_kMaxExportAttempts export attempts — giving up, will stop blocking newer days', + ); } } } @@ -340,9 +398,21 @@ class HealthExporter { // write on failure (best-effort, idempotent re-export corrects it later). var success = true; + // Sleep is the smallest, highest-value Android write. Do it before the + // high-volume minute-HR export can consume Health Connect's API quota. + // The native replace owns SleepSessionRecord cleanup on Android. + if (Platform.isAndroid) { + try { + if (!await _androidSleep.replace(b)) success = false; + } catch (e) { + debugPrint('[health] write Android sleep session: $e'); + success = false; + } + } + // Idempotency: remove OUR previously-written samples for this day (HealthKit / // Health Connect only let an app delete its own data), then re-write fresh. - for (final t in _types) { + for (final t in healthDeleteTypes(isApplePlatform: isApple)) { try { final deleted = await _health.delete( type: t, @@ -367,8 +437,12 @@ class HealthExporter { ? DateTime.fromMillisecondsSinceEpoch(((onMs + offMs) / 2).round()) : dayStart.add(const Duration(hours: 12)); - Future writeAt(HealthDataType type, num? v, HealthDataUnit unit, - DateTime t) async { + Future writeAt( + HealthDataType type, + num? v, + HealthDataUnit unit, + DateTime t, + ) async { if (v == null || v <= 0) return; // absent input, not a failure try { final wrote = await _health.writeHealthData( @@ -386,11 +460,19 @@ class HealthExporter { } // Nightly cardiac/respiratory scalars (single sample at the sleep midpoint). - await writeAt(HealthDataType.RESTING_HEART_RATE, sc('rhr'), - HealthDataUnit.BEATS_PER_MINUTE, mid); + await writeAt( + HealthDataType.RESTING_HEART_RATE, + sc('rhr'), + HealthDataUnit.BEATS_PER_MINUTE, + mid, + ); await writeAt(_hrvType, sc(_hrvScalarKey), HealthDataUnit.MILLISECOND, mid); - await writeAt(HealthDataType.RESPIRATORY_RATE, sc('resp_rate'), - HealthDataUnit.RESPIRATIONS_PER_MINUTE, mid); + await writeAt( + HealthDataType.RESPIRATORY_RATE, + sc('resp_rate'), + HealthDataUnit.RESPIRATIONS_PER_MINUTE, + mid, + ); // Hourly buckets spanning [dayStart, dayEnd), shared by the active/basal // energy writers below. Each bucket is a real elapsed clock-hour (not @@ -414,8 +496,9 @@ class HealthExporter { var cal = sc('calories')?.toDouble() ?? 0.0; try { final rows = await LocalDb.sessionsInRange( - dayStart.millisecondsSinceEpoch ~/ 1000, - (dayEnd.millisecondsSinceEpoch ~/ 1000) - 1); + dayStart.millisecondsSinceEpoch ~/ 1000, + (dayEnd.millisecondsSinceEpoch ~/ 1000) - 1, + ); var workoutCal = 0.0; for (final r in rows) { if ((r['status']?.toString() ?? '') == 'live') continue; @@ -484,7 +567,8 @@ class HealthExporter { 'FROM decoded_onehz ' 'WHERE rec_ts >= ? AND rec_ts < ? AND hr > 0 ' 'GROUP BY minute_ts', - [startTs, endTs]); + [startTs, endTs], + ); } catch (e) { debugPrint('[health] query continuous hr: $e'); success = false; @@ -534,14 +618,7 @@ class HealthExporter { // health 11.1.1 generic SLEEP_* writer instead creates one parent record // per call, fragmenting a night. Android therefore uses our typed native // replace API; Apple Health keeps its existing per-stage samples. - if (Platform.isAndroid) { - try { - if (!await _androidSleep.replace(b)) success = false; - } catch (e) { - debugPrint('[health] write Android sleep session: $e'); - success = false; - } - } else { + if (!Platform.isAndroid) { final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const []; for (final s in segs) { if (s is! Map) continue; @@ -574,8 +651,9 @@ class HealthExporter { List>? rows; try { rows = await LocalDb.sessionsInRange( - dayStart.millisecondsSinceEpoch ~/ 1000, - (dayEnd.millisecondsSinceEpoch ~/ 1000) - 1); + dayStart.millisecondsSinceEpoch ~/ 1000, + (dayEnd.millisecondsSinceEpoch ~/ 1000) - 1, + ); } catch (e) { debugPrint('[health] query workouts: $e'); success = false; @@ -738,7 +816,9 @@ class HealthExporter { static DateTime? _localMidnight(String ymd) { final p = ymd.split('-'); if (p.length != 3) return null; - final y = int.tryParse(p[0]), m = int.tryParse(p[1]), d = int.tryParse(p[2]); + final y = int.tryParse(p[0]), + m = int.tryParse(p[1]), + d = int.tryParse(p[2]); if (y == null || m == null || d == null) return null; return DateTime(y, m, d); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index dfc502b5..fae42d11 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -387,7 +387,7 @@ class AppState extends ChangeNotifier { /// Export all finalized-but-unexported days now. Returns days written. Future healthSyncNow() async { - final n = await _healthExport.exportAll(); + final n = await _healthExport.exportAll(forceRetry: true); return n; } diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index bc746d2a..79016b8b 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:health/health.dart'; +import 'package:openstrap_edge/health/health_export.dart'; import 'package:openstrap_edge/health/health_sleep_session.dart'; int _seconds(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; @@ -50,6 +52,51 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Health Connect sleep-session export regression', () { + test('Android generic cleanup never deletes sleep records', () { + final types = healthDeleteTypes(isApplePlatform: false); + + expect(types, contains(HealthDataType.STEPS)); + expect( + types, + isNot( + containsAll([ + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_LIGHT, + HealthDataType.SLEEP_AWAKE, + HealthDataType.SLEEP_SESSION, + ]), + ), + ); + expect(types.where((type) => type.name.startsWith('SLEEP_')), isEmpty); + }); + + test('manual sync bypasses retry backoff and attempt cap', () { + final now = DateTime(2026, 8, 5, 13); + + expect( + shouldAttemptHealthExport( + attempts: 6, + maxAttempts: 6, + now: now, + lastAttempt: now.subtract(const Duration(seconds: 1)), + backoff: const Duration(hours: 1), + ), + isFalse, + ); + expect( + shouldAttemptHealthExport( + attempts: 6, + maxAttempts: 6, + now: now, + lastAttempt: now.subtract(const Duration(seconds: 1)), + backoff: const Duration(hours: 1), + force: true, + ), + isTrue, + ); + }); + test('normalizes one complete cross-midnight session with every stage', () { final session = normalizeHealthSleepSession(_overnightBundle()); From 044d5077a051ca4dbccbe992568a7ee810abc90e Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Wed, 5 Aug 2026 14:23:29 +0200 Subject: [PATCH 03/19] fix(health): address sleep export review findings --- .../HealthConnectSleepWriter.kt | 30 ++++++-- lib/health/health_export.dart | 35 ++++++--- lib/health/health_sleep_session.dart | 5 +- lib/state/app_state.dart | 9 ++- test/health_sleep_export_test.dart | 75 ++++++++++++++++++- 5 files changed, 126 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt index ed93c829..6234eb96 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt @@ -11,19 +11,20 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.time.Instant import java.time.ZoneId +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** Writes one Health Connect sleep parent containing all normalized stages. */ object HealthConnectSleepWriter { private const val TAG = "OpenStrapSleepExport" private const val CHANNEL = "openstrap/health_connect_sleep" private const val REPLACE_SLEEP_SESSION = "replaceSleepSession" - private const val RECORDING_METHOD_AUTOMATIC = 2 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val replaceMutex = Mutex() @@ -36,11 +37,13 @@ object HealthConnectSleepWriter { return@setMethodCallHandler } scope.launch { - result.success(replace(app, call)) + val replaced = withContext(Dispatchers.IO) { replace(app, call) } + result.success(replaced) } } } + @Suppress("TooGenericExceptionCaught") private suspend fun replace(context: Context, call: MethodCall): Boolean { return replaceMutex.withLock { try { @@ -63,7 +66,11 @@ object HealthConnectSleepWriter { client.insertRecords(listOf(session)).recordIdsList.size == 1 } } + } catch (cancelled: CancellationException) { + throw cancelled } catch (error: Exception) { + // Health Connect may surface several unrelated platform and + // transport exceptions; the channel reports all of them as false. Log.e(TAG, "SleepSessionRecord replace failed", error) false } @@ -71,12 +78,17 @@ object HealthConnectSleepWriter { } private fun buildSession(call: MethodCall): SleepSessionRecord? { - val start = call.argument("startTime")?.let(Instant::ofEpochMilli) ?: return null - val end = call.argument("endTime")?.let(Instant::ofEpochMilli) ?: return null + val start = (call.argument("startTime") as? Number) + ?.toLong()?.let(Instant::ofEpochMilli) ?: return null + val end = (call.argument("endTime") as? Number) + ?.toLong()?.let(Instant::ofEpochMilli) ?: return null if (!start.isBefore(end)) return null - val rawStages = call.argument>>("stages").orEmpty() - val stages = rawStages.mapNotNull(::buildStage).sortedBy { it.startTime } + val rawStages = call.argument>("stages").orEmpty() + val stages = rawStages + .mapNotNull { (it as? Map<*, *>)?.let(::buildStage) } + .sortedBy { it.startTime } + if (stages.isEmpty()) return null var previousEnd = start for (stage in stages) { if (stage.startTime.isBefore(start) || stage.endTime.isAfter(end)) return null @@ -93,11 +105,13 @@ object HealthConnectSleepWriter { endZoneOffset = zoneRules.getOffset(end), title = "OpenStrap sleep", stages = stages, - metadata = Metadata(recordingMethod = RECORDING_METHOD_AUTOMATIC), + metadata = Metadata( + recordingMethod = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, + ), ) } - private fun buildStage(raw: Map): SleepSessionRecord.Stage? { + private fun buildStage(raw: Map<*, *>): SleepSessionRecord.Stage? { val start = (raw["startTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) ?: return null val end = (raw["endTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 96e4bd31..4977a9aa 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -77,6 +77,22 @@ bool shouldAttemptHealthExport({ return lastAttempt == null || now.difference(lastAttempt) >= backoff; } +class HealthExportSingleFlight { + Future? _inFlight; + + Future run(Future Function() export) { + final current = _inFlight; + if (current != null) return current; + + late final Future operation; + operation = Future.sync(export).whenComplete(() { + if (identical(_inFlight, operation)) _inFlight = null; + }); + _inFlight = operation; + return operation; + } +} + class HealthExporter { final _health = Health(); final _androidSleep = HealthConnectSleepSessionExporter( @@ -618,16 +634,15 @@ class HealthExporter { // health 11.1.1 generic SLEEP_* writer instead creates one parent record // per call, fragmenting a night. Android therefore uses our typed native // replace API; Apple Health keeps its existing per-stage samples. - if (!Platform.isAndroid) { + if (isApple) { final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const []; for (final s in segs) { if (s is! Map) continue; final st = (s['start'] as num?)?.toInt(); final en = (s['end'] as num?)?.toInt(); - final stage = s['stage']?.toString(); + final stage = healthSleepStageOf(s['stage']?.toString()); if (st == null || en == null || en <= st || stage == null) continue; final type = _sleepType(stage); - if (type == null) continue; try { final wrote = await _health.writeHealthData( value: 0, @@ -747,20 +762,16 @@ class HealthExporter { } } - HealthDataType? _sleepType(String stage) { + HealthDataType _sleepType(HealthSleepStage stage) { switch (stage) { - case 'deep': + case HealthSleepStage.deep: return HealthDataType.SLEEP_DEEP; - case 'rem': + case HealthSleepStage.rem: return HealthDataType.SLEEP_REM; - case 'light': - case 'nrem': + case HealthSleepStage.light: return HealthDataType.SLEEP_LIGHT; - case 'wake': - case 'awake': + case HealthSleepStage.awake: return HealthDataType.SLEEP_AWAKE; - default: - return null; } } diff --git a/lib/health/health_sleep_session.dart b/lib/health/health_sleep_session.dart index 74911869..4e6f274e 100644 --- a/lib/health/health_sleep_session.dart +++ b/lib/health/health_sleep_session.dart @@ -60,7 +60,7 @@ HealthSleepSession? normalizeHealthSleepSession(Map bundle) { if (raw is! Map) continue; final startSeconds = (raw['start'] as num?)?.toInt(); final endSeconds = (raw['end'] as num?)?.toInt(); - final stage = _stageOf(raw['stage']?.toString()); + final stage = healthSleepStageOf(raw['stage']?.toString()); if (startSeconds == null || endSeconds == null || stage == null) continue; final rawStart = DateTime.fromMillisecondsSinceEpoch(startSeconds * 1000); @@ -103,7 +103,7 @@ HealthSleepSession? normalizeHealthSleepSession(Map bundle) { return HealthSleepSession(start: start, end: end, stages: normalized); } -HealthSleepStage? _stageOf(String? stage) { +HealthSleepStage? healthSleepStageOf(String? stage) { switch (stage) { case 'wake': case 'awake': @@ -161,6 +161,7 @@ class HealthConnectSleepSessionExporter { Future replace(Map bundle) async { final session = normalizeHealthSleepSession(bundle); if (session == null) return true; + if (session.stages.isEmpty) return false; return writer.replace(session); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index fae42d11..4ce4acb7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -341,6 +341,8 @@ class AppState extends ChangeNotifier { // ── platform health export (Apple Health / Health Connect) ────────────────── final HealthExporter _healthExport = HealthExporter(); + final HealthExportSingleFlight _healthExportSingleFlight = + HealthExportSingleFlight(); HealthLinkState healthState = HealthLinkState.unknown; bool healthSyncEnabled = false; static const String _kHealthSync = 'health_sync'; @@ -386,10 +388,9 @@ class AppState extends ChangeNotifier { } /// Export all finalized-but-unexported days now. Returns days written. - Future healthSyncNow() async { - final n = await _healthExport.exportAll(forceRetry: true); - return n; - } + Future healthSyncNow() => _healthExportSingleFlight.run( + () => _healthExport.exportAll(forceRetry: true), + ); /// Session-triggered Health export for one just-finished workout (issue /// #130) — used by callers outside this class (e.g. confirming an diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 79016b8b..131c9be0 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -71,6 +71,16 @@ void main() { expect(types.where((type) => type.name.startsWith('SLEEP_')), isEmpty); }); + test('Apple and Android share one hypnogram stage vocabulary', () { + expect(healthSleepStageOf('wake'), HealthSleepStage.awake); + expect(healthSleepStageOf('awake'), HealthSleepStage.awake); + expect(healthSleepStageOf('rem'), HealthSleepStage.rem); + expect(healthSleepStageOf('light'), HealthSleepStage.light); + expect(healthSleepStageOf('nrem'), HealthSleepStage.light); + expect(healthSleepStageOf('deep'), HealthSleepStage.deep); + expect(healthSleepStageOf('unknown'), isNull); + }); + test('manual sync bypasses retry backoff and attempt cap', () { final now = DateTime(2026, 8, 5, 13); @@ -97,6 +107,40 @@ void main() { ); }); + test( + 'manual health exports are single-flight and reset after completion', + () async { + final gate = HealthExportSingleFlight(); + final firstResult = Completer(); + var calls = 0; + + Future export() { + calls++; + return calls == 1 ? firstResult.future : Future.value(2); + } + + final first = gate.run(export); + final overlapping = gate.run(export); + expect(calls, 1); + + firstResult.complete(1); + expect(await first, 1); + expect(await overlapping, 1); + expect(await gate.run(export), 2); + expect(calls, 2); + }, + ); + + test('single-flight preserves synchronous errors and resets', () async { + final gate = HealthExportSingleFlight(); + + await expectLater( + gate.run(() => throw StateError('boom')), + throwsA(isA()), + ); + expect(await gate.run(() async => 3), 3); + }); + test('normalizes one complete cross-midnight session with every stage', () { final session = normalizeHealthSleepSession(_overnightBundle()); @@ -215,6 +259,33 @@ void main() { expect(args['stages'] as List, hasLength(6)); }); + test( + 'an empty normalized hypnogram is retryable and never replaces native data', + () async { + const channel = MethodChannel('openstrap/test_health_connect_empty'); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter( + channel: channel, + ), + ); + final bundle = _overnightBundle(); + ((bundle['series'] as Map)['hypnogram'] as List).clear(); + + expect(await exporter.replace(bundle), isFalse); + expect(calls, 0, reason: 'empty stages must not delete native sleep'); + }, + ); + test( 're-export uses the replace operation and a false result propagates', () async { @@ -242,7 +313,7 @@ void main() { expect(await exporter.replace(_overnightBundle()), isTrue); expect(await exporter.replace(_overnightBundle()), isFalse); - expect(storedParents, hasLength(1)); + expect(writes, 2, reason: 'each export sends exactly one replace call'); expect(storedParents.single['stages'] as List, hasLength(6)); }, ); @@ -283,7 +354,7 @@ void main() { final first = exporter.replace(_overnightBundle()); await firstEntered.future; final second = exporter.replace(_overnightBundle()); - await Future.delayed(Duration.zero); + await pumpEventQueue(); expect(calls, 1, reason: 'the second native replace must stay queued'); releaseFirst.complete(true); From 93fb6546159ff44c01a9153c17ed2607dd571920 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Wed, 5 Aug 2026 14:34:47 +0200 Subject: [PATCH 04/19] fix(health): serialize automatic exports --- lib/state/app_state.dart | 9 ++++++--- test/health_sleep_export_test.dart | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4ce4acb7..6397108b 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -388,8 +388,11 @@ class AppState extends ChangeNotifier { } /// Export all finalized-but-unexported days now. Returns days written. - Future healthSyncNow() => _healthExportSingleFlight.run( - () => _healthExport.exportAll(forceRetry: true), + Future healthSyncNow() => _runHealthExport(forceRetry: true); + + Future _runHealthExport({bool forceRetry = false}) => + _healthExportSingleFlight.run( + () => _healthExport.exportAll(forceRetry: forceRetry), ); /// Session-triggered Health export for one just-finished workout (issue @@ -922,7 +925,7 @@ class AppState extends ChangeNotifier { if (healthSyncEnabled) { unawaited(() async { try { - final n = await _healthExport.exportAll(); + final n = await _runHealthExport(); if (n > 0) _log('[health] exported $n day(s)'); } catch (e) { _log('[health] export failed: $e'); diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 131c9be0..617907ea 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -141,6 +142,20 @@ void main() { expect(await gate.run(() async => 3), 3); }); + test('AppState centralizes every full health export behind one gate', () { + final source = File('lib/state/app_state.dart').readAsStringSync(); + final directCalls = RegExp( + r'_healthExport\.exportAll\(', + ).allMatches(source); + + expect( + directCalls, + hasLength(1), + reason: 'automatic and forced exports must share one guarded method', + ); + expect(source, contains('Future _runHealthExport(')); + }); + test('normalizes one complete cross-midnight session with every stage', () { final session = normalizeHealthSleepSession(_overnightBundle()); From 1bec1d8ba2b8ae8802b0a0523651323e90c7d925 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 12:30:53 +0200 Subject: [PATCH 05/19] docs: design Health Connect heart-rate batching --- ...-health-connect-heart-rate-batch-design.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-health-connect-heart-rate-batch-design.md diff --git a/docs/superpowers/specs/2026-08-06-health-connect-heart-rate-batch-design.md b/docs/superpowers/specs/2026-08-06-health-connect-heart-rate-batch-design.md new file mode 100644 index 00000000..4278e85b --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-health-connect-heart-rate-batch-design.md @@ -0,0 +1,72 @@ +# Health Connect heart-rate batch export design + +## Problem + +OpenStrap exports every minute-average heart-rate value through +`health.writeHealthData`. In `health 11.1.1`, each Android call creates and +inserts a separate `HeartRateRecord`. A manual export therefore consumes the +Health Connect API-call quota before recent sleep sessions can be replaced. +Health Connect then returns `API call quota exceeded`; the exporter propagates +the failure, but downstream apps continue showing an older sleep session. + +## Scope + +- Treat the newest detected Android sleep session as the highest-priority + Health Connect write. +- Preserve the existing Apple Health export path. +- Preserve minute-average heart-rate samples on Android. +- Change only Android continuous-heart-rate writes from one call per minute to + one typed batch operation per calendar day. +- Keep the existing dedicated Android sleep-session writer and its ordering + before other per-day health data. +- Propagate native `false` results and exceptions as an incomplete day export. +- Do not change derivation, sleep staging, database data, or UI behavior. + +## Design + +Add a project-local Android MethodChannel API that accepts a local calendar-day +start/end and ordered minute samples. Dart validates and normalizes the payload: +samples must be inside the parent interval, ordered, unique by timestamp, and +within Health Connect's valid heart-rate range. + +The Android writer serializes replacements with a mutex, validates the payload +again, deletes OpenStrap's prior `HeartRateRecord` data in the exact day window, +and inserts one `HeartRateRecord` whose `samples` list contains all minute +averages. Health Connect therefore sees two API calls per re-export (delete and +insert), rather than approximately 1,440 individual inserts plus deletion. + +Before Android starts the normal oldest-to-newest bulk loop, it exports the +newest detected sleep session once. If that priority write fails, the bulk loop +stops immediately so heart rate, workouts, old retry days, and energy cannot +consume newly recovered quota ahead of sleep. When it succeeds, the later +per-day loop reuses that result and does not write the same sleep session twice. +Older sleep sessions continue through the existing per-day export path. + +When no valid samples exist, the operation is a successful no-op and does not +delete existing data. A delete/insert exception or an unexpected insertion +result returns `false`. Cancellation is rethrown. Health Connect work runs on +`Dispatchers.IO`, while MethodChannel results return on the main scope. + +## Alternatives considered + +1. Stop Android continuous-heart-rate export. This saves quota but loses data. +2. Throttle individual writes. At Health Connect's observed refill rate, a full + day could take hours and remain vulnerable to interruption. +3. Vendor `health`. This creates a large dependency-maintenance burden for a + narrow missing API. + +The project-local typed writer is the smallest option that preserves behavior. + +## Testing + +- A Dart regression test proves a full day's minute values produce exactly one + native batch call and no generic Android heart-rate writes. +- A regression test proves the newest Android sleep write happens before any + other health operation, aborts bulk export on `false`, and is not duplicated + later in the same export pass. +- Tests cover ordering, duplicate timestamps, clipping/invalid values, empty + input, and propagation of a native `false` result. +- Android compilation verifies the pinned Health Connect constructor/API. +- Run focused health tests, formatting, `flutter analyze`, and a release APK + build. Install the APK and manually verify that a fresh sync writes the current + OpenStrap sleep session after the Health Connect quota has recovered. From 79f4604a8ba3e8d4e804edff8abf7f1e6026f3e7 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 12:36:48 +0200 Subject: [PATCH 06/19] docs: plan Health Connect quota fix --- ...h-connect-priority-and-heart-rate-batch.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-health-connect-priority-and-heart-rate-batch.md diff --git a/docs/superpowers/plans/2026-08-06-health-connect-priority-and-heart-rate-batch.md b/docs/superpowers/plans/2026-08-06-health-connect-priority-and-heart-rate-batch.md new file mode 100644 index 00000000..3abaf29b --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-health-connect-priority-and-heart-rate-batch.md @@ -0,0 +1,246 @@ +# Health Connect Sleep Priority and Heart-Rate Batch Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure the newest Android sleep session is always attempted before bulk health data and reduce continuous-heart-rate export from roughly 1,440 Health Connect calls per day to one typed replacement. + +**Architecture:** Add a small testable priority-sleep coordinator in Dart, then add a typed Dart/MethodChannel/Android writer for one daily `HeartRateRecord` containing all minute samples. `HealthExporter` will call the priority coordinator before its existing oldest-to-newest loop, reuse that result to avoid a duplicate sleep write, and route Android heart rate through the new batch writer while preserving Apple Health's current generic writes. + +**Tech Stack:** Flutter/Dart, `flutter_test`, Android Kotlin, `androidx.health.connect:connect-client:1.1.0-alpha07`, Flutter `MethodChannel`. + +## Global Constraints + +- The newest detected Android sleep session is the highest-priority Health Connect write. +- If the priority sleep write returns `false` or throws, stop before bulk metrics consume quota. +- Preserve minute-average heart-rate samples on Android. +- Preserve the existing Apple Health export path. +- Check every Boolean write result; `false` keeps the export unsuccessful. +- Do not change derivation, sleep staging, database contents, or UI behavior. +- Keep changes limited to health export code, Android channel registration, tests, and these design/plan documents. + +--- + +## File Structure + +- Create `lib/health/health_heart_rate_batch.dart`: typed sample model, normalization, platform-neutral export routing, and MethodChannel writer. +- Create `android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt`: validate and replace one daily `HeartRateRecord` on `Dispatchers.IO`. +- Modify `android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt`: register the new writer. +- Modify `lib/health/health_export.dart`: prioritize newest Android sleep and route continuous HR through the batch API. +- Modify `test/health_sleep_export_test.dart`: cover sleep-first/abort/no-duplicate behavior. +- Create `test/health_heart_rate_export_test.dart`: cover normalization, batching, Apple preservation, empty data, and `false` propagation. + +--- + +### Task 1: Prioritize the newest Android sleep session + +**Files:** +- Modify: `lib/health/health_export.dart` +- Modify: `test/health_sleep_export_test.dart` + +**Interfaces:** +- Produces: `PrioritySleepExportResult` with `String? date` and `bool succeeded`. +- Produces: `exportNewestPrioritySleep({required Iterable>> newestFirstDays, required Future Function(Map) write})`. +- Changes: `_exportDay(..., {bool androidSleepAlreadyWritten = false})`. + +- [ ] **Step 1: Write failing coordinator tests** + +Add tests that call the wished-for coordinator with three newest-first bundles. The first bundle has no valid sleep, the second has `_overnightBundle()`, and the third has an older valid sleep. Assert that only the second bundle is written and its date is returned. Add a second test whose writer returns `false` and assert `succeeded == false` and that a supplied bulk callback is never reached. Add a third assertion that `_exportDay` has a skip flag so the same date is not written twice in one export pass. + +```dart +test('newest detected sleep is written before bulk and only once', () async { + final writes = >[]; + final result = await exportNewestPrioritySleep( + newestFirstDays: [ + MapEntry('2026-08-06', {}), + MapEntry('2026-08-05', _overnightBundle()), + MapEntry('2026-08-04', _overnightBundle()), + ], + write: (bundle) async { + writes.add(bundle); + return true; + }, + ); + + expect(result.date, '2026-08-05'); + expect(result.succeeded, isTrue); + expect(writes, hasLength(1)); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `flutter test test/health_sleep_export_test.dart --reporter expanded` + +Expected: compilation fails because `PrioritySleepExportResult` and `exportNewestPrioritySleep` do not exist. + +- [ ] **Step 3: Implement the minimal priority coordinator** + +In `health_export.dart`, add the result type and function. Iterate newest-first, use `normalizeHealthSleepSession` only to identify the first bundle containing a valid detected sleep, call `write` exactly once, and return its date and Boolean result. Return `{date: null, succeeded: true}` when no pending bundle contains sleep. + +```dart +class PrioritySleepExportResult { + const PrioritySleepExportResult({required this.date, required this.succeeded}); + final String? date; + final bool succeeded; +} + +Future exportNewestPrioritySleep({ + required Iterable>> newestFirstDays, + required Future Function(Map) write, +}) async { + for (final day in newestFirstDays) { + if (normalizeHealthSleepSession(day.value) == null) continue; + return PrioritySleepExportResult( + date: day.key, + succeeded: await write(day.value), + ); + } + return const PrioritySleepExportResult(date: null, succeeded: true); +} +``` + +- [ ] **Step 4: Integrate priority sleep into `exportAll`** + +Decode pending rows once into date/bundle entries. On Android, call `exportNewestPrioritySleep` before the normal loop using newest-first order and `_androidSleep.replace`. If it fails or throws, record the affected date in the existing retry-state shape (`attempts`, `last_ms`, `finalized`), persist it, and return `0` before any generic health write. Pass `androidSleepAlreadyWritten: date == priorityResult.date` into `_exportDay`; guard its native sleep block with `if (Platform.isAndroid && !androidSleepAlreadyWritten)`. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run: `flutter test test/health_sleep_export_test.dart --reporter expanded` + +Expected: all sleep-export tests pass and the coordinator tests prove newest-first, abort-on-false, and one-write behavior. + +- [ ] **Step 6: Commit Task 1** + +```powershell +git add lib/health/health_export.dart test/health_sleep_export_test.dart +git commit -m "fix(health): prioritize current Android sleep export" +``` + +--- + +### Task 2: Batch Android minute heart-rate samples + +**Files:** +- Create: `lib/health/health_heart_rate_batch.dart` +- Create: `android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt` +- Modify: `android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt` +- Modify: `lib/health/health_export.dart` +- Create: `test/health_heart_rate_export_test.dart` + +**Interfaces:** +- Produces: `HealthHeartRateSample(DateTime time, int beatsPerMinute)` and `toMap()`. +- Produces: `normalizeHealthHeartRateSamples(List> rows, DateTime start, DateTime end)`. +- Produces: `HealthConnectHeartRateWriter.replaceDay(DateTime start, DateTime end, List samples) -> Future`. +- Produces: `MethodChannelHealthConnectHeartRateWriter` on channel `openstrap/health_connect_heart_rate`, method `replaceHeartRateDay`. +- Produces: `exportContinuousHeartRateDay(...) -> Future` that selects one Android batch call or preserved generic per-minute calls. + +- [ ] **Step 1: Write failing Dart tests for the wished-for API** + +Create `test/health_heart_rate_export_test.dart`. Build rows containing out-of-order timestamps, a duplicate minute, values below 1 and above 300, and timestamps outside the day. Assert normalization returns ordered unique in-range samples. Mock a MethodChannel and assert Android makes exactly one `replaceHeartRateDay` call containing every normalized sample. Return `false` from the handler and assert the export returns `false`. Pass `useAndroidBatch: false` with a generic callback and assert one generic call per valid sample, preserving Apple behavior. Assert empty normalized input returns `true` without invoking either writer. + +- [ ] **Step 2: Run the new test and verify RED** + +Run: `flutter test test/health_heart_rate_export_test.dart --reporter expanded` + +Expected: compilation fails because `health_heart_rate_batch.dart` and its types do not exist. + +- [ ] **Step 3: Implement Dart normalization and routing** + +Create the file with an injected interface and MethodChannel implementation. Convert SQL `minute_ts` seconds to `DateTime`, truncate `avg_hr` with `toInt()` to match `health 11.1.1`, reject values outside `1..300`, clip by dropping samples outside `[start, end)`, sort by timestamp, and keep one sample per timestamp. For Android, call `replaceDay` once. For Apple, call the injected generic writer for each sample with end time `sample.time + 1 minute`, aggregating every Boolean result without stopping after a failure. + +- [ ] **Step 4: Run the Dart test and verify GREEN** + +Run: `flutter test test/health_heart_rate_export_test.dart --reporter expanded` + +Expected: all heart-rate batch tests pass. + +- [ ] **Step 5: Write the native Android batch writer** + +Create `HealthConnectHeartRateWriter.kt` following the existing sleep writer's channel/coroutine pattern. Parse `startTime`, `endTime`, and `samples` defensively through `Number.toLong()`. Reject invalid parents, invalid BPM values, duplicate/out-of-order samples, and samples outside `[start, end)`. Treat an empty sample list as `true` without deletion. Under a mutex on `Dispatchers.IO`, delete `HeartRateRecord` data for the exact day and insert one record: + +```kotlin +HeartRateRecord( + startTime = start, + endTime = end, + startZoneOffset = ZoneId.systemDefault().rules.getOffset(start), + endZoneOffset = ZoneId.systemDefault().rules.getOffset(end), + samples = samples, + metadata = Metadata( + recordingMethod = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, + ), +) +``` + +Return `insertRecords(listOf(record)).recordIdsList.size == 1`; rethrow `CancellationException`; log and return `false` for other Health Connect exceptions. Register it from `NativeChannels.register`. + +- [ ] **Step 6: Route `HealthExporter` through the batch API** + +Add a constructor-injected `_androidHeartRate` with a default `MethodChannelHealthConnectHeartRateWriter`. Replace the existing continuous-HR loop with `exportContinuousHeartRateDay`, passing `useAndroidBatch: Platform.isAndroid`, the queried rows, and an Apple generic callback that calls `_health.writeHealthData` with `HealthDataType.HEART_RATE`. If the helper returns `false`, set the day-level `success = false`. + +- [ ] **Step 7: Run formatting and focused tests** + +Run: + +```powershell +dart format lib/health/health_heart_rate_batch.dart lib/health/health_export.dart test/health_heart_rate_export_test.dart test/health_sleep_export_test.dart +flutter test test/health_heart_rate_export_test.dart test/health_sleep_export_test.dart --reporter expanded +``` + +Expected: formatting completes and all focused tests pass. + +- [ ] **Step 8: Compile the Android writer and commit Task 2** + +Run: `flutter build apk --debug` + +Expected: Kotlin compiles against pinned `connect-client:1.1.0-alpha07` and the debug APK succeeds. + +```powershell +git add android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt lib/health/health_heart_rate_batch.dart lib/health/health_export.dart test/health_heart_rate_export_test.dart +git commit -m "fix(health): batch Android heart-rate export" +``` + +--- + +### Task 3: Verify, publish, and test on the Pixel + +**Files:** +- No new production files. +- Verify only the files changed in Tasks 1 and 2 plus the approved design/plan documents. + +**Interfaces:** +- Consumes: all Task 1 and Task 2 interfaces. +- Produces: release APK at `build/app/outputs/flutter-apk/app-release.apk` and desktop copy `C:\Users\felix\Desktop\OpenStrap-Edge-fix-193.apk`. + +- [ ] **Step 1: Run static analysis and focused tests** + +```powershell +flutter analyze +flutter test test/health_heart_rate_export_test.dart test/health_sleep_export_test.dart --reporter expanded +``` + +Expected: analyzer reports `No issues found!`; all focused tests pass. + +- [ ] **Step 2: Run the full test suite** + +Run: `flutter test --reporter expanded` + +Expected: no new failure relative to the known Windows DST and timing-sensitive DeriveScheduler failures. Record exact pass/skip/fail totals. + +- [ ] **Step 3: Build the release APK** + +Run: `flutter build apk --release` + +Expected: `build/app/outputs/flutter-apk/app-release.apk` is produced successfully. + +- [ ] **Step 4: Copy, hash, install, and open the APK** + +Copy the release APK to `C:\Users\felix\Desktop\OpenStrap-Edge-fix-193.apk`, calculate SHA-256, run `adb install -r` against the connected device, and open `wtf.openstrap.openstrap_edge/.MainActivity`. Do not uninstall the app or clear its database. + +- [ ] **Step 5: Verify the real export boundary** + +Clear only logcat, tap `Sync now`, and confirm in logs that the newest sleep write occurs before heart-rate/workout writes, there is no burst of per-minute `HEART_RATE` calls, and the current sleep replacement succeeds. Open Health Connect and verify one OpenStrap sleep parent for `01:36–08:20` with all stages. Google/Fitbit refresh may remain asynchronous; distinguish its cache from the Health Connect source of truth. + +- [ ] **Step 6: Commit any verification-only test adjustment, push, and update PR** + +If verification required no code change, do not create an empty commit. Push `fix/health-connect-sleep-session` to `origin`, then update PR #196 with the confirmed quota root cause, batching fix, commands/results, and APK hash. + From cce401f543593b18b8818e25c63d818b8f99174e Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 12:37:08 +0200 Subject: [PATCH 07/19] chore: ignore local agent workspaces --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 0548b9f7..f1331ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ scratch_* /fetch_apps.js #adding firebase_options back so that we dont commit it accidentally in future releases lib/firebase_options.dart + +# Local agent worktrees and execution ledgers. +/.worktrees/ +/.superpowers/ From b1fae026bb266d3ddc223851cb5e766947a26e90 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 12:49:59 +0200 Subject: [PATCH 08/19] fix(health): prioritize current Android sleep export --- lib/health/health_export.dart | 112 +++++++++++++++++++++++++---- test/health_sleep_export_test.dart | 71 ++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 4977a9aa..9806abe5 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -77,6 +77,30 @@ bool shouldAttemptHealthExport({ return lastAttempt == null || now.difference(lastAttempt) >= backoff; } +class PrioritySleepExportResult { + const PrioritySleepExportResult({ + required this.date, + required this.succeeded, + }); + + final String? date; + final bool succeeded; +} + +Future exportNewestPrioritySleep({ + required Iterable>> newestFirstDays, + required Future Function(Map) write, +}) async { + for (final day in newestFirstDays) { + if (normalizeHealthSleepSession(day.value) == null) continue; + return PrioritySleepExportResult( + date: day.key, + succeeded: await write(day.value), + ); + } + return const PrioritySleepExportResult(date: null, succeeded: true); +} + class HealthExportSingleFlight { Future? _inFlight; @@ -292,18 +316,74 @@ class HealthExporter { final retryState = await _loadRetryState(); var retryStateDirty = false; final rows = await LocalDb.recentDayResults(400); // newest-first - final ascending = rows.reversed.toList(); - var done = 0; - var newCursor = cursor; - var prefixContiguous = true; // still extending the finalized prefix? - for (final row in ascending) { + final pendingDays = + <({String date, bool finalized, Map? bundle})>[]; + for (final row in rows) { final date = (row['day_id'] ?? row['date'])?.toString(); if (date == null || date.isEmpty) continue; - if (cursor.isNotEmpty && date.compareTo(cursor) <= 0) { - continue; // immutable finalized prefix — already exported + if (cursor.isNotEmpty && date.compareTo(cursor) <= 0) continue; + pendingDays.add(( + date: date, + finalized: (row['finalized'] as num?)?.toInt() == 1, + bundle: _decode(row['payload_json']), + )); + } + + var priorityResult = const PrioritySleepExportResult( + date: null, + succeeded: true, + ); + if (Platform.isAndroid) { + final priorityDays = pendingDays + .where( + (day) => day.bundle != null && day.bundle!['skipped'] != true, + ) + .map((day) => MapEntry(day.date, day.bundle!)) + .toList(); + Future recordPriorityFailure(String date) async { + final entry = (retryState[date] as Map?)?.cast(); + retryState[date] = { + 'attempts': ((entry?['attempts'] as num?)?.toInt() ?? 0) + 1, + 'last_ms': DateTime.now().millisecondsSinceEpoch, + 'finalized': pendingDays + .firstWhere((pending) => pending.date == date) + .finalized, + }; + await LocalDb.setCursor(_kRetryCursor, jsonEncode(retryState)); } - final finalized = (row['finalized'] as num?)?.toInt() == 1; - final bundle = _decode(row['payload_json']); + + String? priorityDate; + try { + priorityResult = await exportNewestPrioritySleep( + newestFirstDays: priorityDays, + write: (bundle) { + priorityDate = priorityDays + .firstWhere((day) => identical(day.value, bundle)) + .key; + return _androidSleep.replace(bundle); + }, + ); + } catch (e) { + debugPrint('[health] write priority Android sleep session: $e'); + final failedPriorityDate = priorityDate; + if (failedPriorityDate != null) { + await recordPriorityFailure(failedPriorityDate); + } + return 0; + } + if (!priorityResult.succeeded) { + await recordPriorityFailure(priorityResult.date!); + return 0; + } + } + + var done = 0; + var newCursor = cursor; + var prefixContiguous = true; // still extending the finalized prefix? + for (final day in pendingDays.reversed) { + final date = day.date; + final finalized = day.finalized; + final bundle = day.bundle; if (bundle == null || bundle['skipped'] == true) { if (!finalized) prefixContiguous = false; continue; @@ -343,7 +423,11 @@ class HealthExporter { // Not due for retry yet — don't hammer the health store on every // drain/derive pass; counts as "not done" for the cursor below. } else { - ok = await _exportDay(date, bundle); // delete-then-write (idempotent) + ok = await _exportDay( + date, + bundle, + androidSleepAlreadyWritten: date == priorityResult.date, + ); // delete-then-write (idempotent) if (ok) { if (entry != null) { retryState.remove(date); @@ -399,7 +483,11 @@ class HealthExporter { /// Write one day's metrics. DELETES our prior samples for the day window first /// (so a re-derive overwrites instead of duplicating). Best-effort; never throws. - Future _exportDay(String date, Map b) async { + Future _exportDay( + String date, + Map b, { + bool androidSleepAlreadyWritten = false, + }) async { final dayStart = _localMidnight(date); if (dayStart == null) return false; // DST-safe next local midnight (calendar-field construction, NOT +24h of @@ -417,7 +505,7 @@ class HealthExporter { // Sleep is the smallest, highest-value Android write. Do it before the // high-volume minute-HR export can consume Health Connect's API quota. // The native replace owns SleepSessionRecord cleanup on Android. - if (Platform.isAndroid) { + if (Platform.isAndroid && !androidSleepAlreadyWritten) { try { if (!await _androidSleep.replace(b)) success = false; } catch (e) { diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 617907ea..94806fe4 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -53,6 +53,77 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Health Connect sleep-session export regression', () { + test( + 'newest detected sleep is written before bulk and only once', + () async { + final writes = >[]; + final newestSleep = _overnightBundle(); + + final result = await exportNewestPrioritySleep( + newestFirstDays: [ + MapEntry('2026-08-06', {}), + MapEntry('2026-08-05', newestSleep), + MapEntry('2026-08-04', _overnightBundle()), + ], + write: (bundle) async { + writes.add(bundle); + return true; + }, + ); + + expect(result.date, '2026-08-05'); + expect(result.succeeded, isTrue); + expect(writes, hasLength(1)); + expect(writes.single, same(newestSleep)); + }, + ); + + test('failed priority sleep write reports failure', () async { + var writes = 0; + + final result = await exportNewestPrioritySleep( + newestFirstDays: [MapEntry('2026-08-05', _overnightBundle())], + write: (bundle) async { + writes++; + return false; + }, + ); + + expect(result.date, '2026-08-05'); + expect(result.succeeded, isFalse); + expect(writes, 1); + }); + + test( + 'failed priority sleep stops bulk export and avoids duplicate sleep', + () { + final source = File('lib/health/health_export.dart').readAsStringSync(); + final exportAll = source.substring( + source.indexOf('Future exportAll'), + ); + final priority = exportAll.indexOf('exportNewestPrioritySleep('); + final bulkLoop = exportAll.indexOf( + 'for (final day in pendingDays.reversed)', + ); + + expect(priority, greaterThanOrEqualTo(0)); + expect(bulkLoop, greaterThan(priority)); + expect( + exportAll.indexOf('if (!priorityResult.succeeded)', priority), + greaterThan(priority), + ); + expect(exportAll.indexOf('return 0;', priority), greaterThan(priority)); + expect( + source, + contains('androidSleepAlreadyWritten: date == priorityResult.date'), + ); + expect( + source, + contains('if (Platform.isAndroid && !androidSleepAlreadyWritten)'), + ); + }, + ); + test('Android generic cleanup never deletes sleep records', () { final types = healthDeleteTypes(isApplePlatform: false); From 2f8b929dd6b845ad741dc5cbbd70abf37f811085 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 13:01:56 +0200 Subject: [PATCH 09/19] fix(health): respect sleep export retry policy --- lib/health/health_export.dart | 318 +++++++++++++++++------------ test/health_sleep_export_test.dart | 84 +++++--- 2 files changed, 246 insertions(+), 156 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 9806abe5..a09b62a6 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -101,6 +101,20 @@ Future exportNewestPrioritySleep({ return const PrioritySleepExportResult(date: null, succeeded: true); } +Future exportPrioritySleepBeforeBulk({ + required Iterable>> newestFirstDays, + required Future Function(Map) write, + required Future Function(String? androidSleepAlreadyWritten) exportBulk, +}) async { + final priorityResult = await exportNewestPrioritySleep( + newestFirstDays: newestFirstDays, + write: write, + ); + if (!priorityResult.succeeded) return priorityResult; + await exportBulk(priorityResult.date); + return priorityResult; +} + class HealthExportSingleFlight { Future? _inFlight; @@ -329,152 +343,192 @@ class HealthExporter { )); } - var priorityResult = const PrioritySleepExportResult( - date: null, - succeeded: true, - ); - if (Platform.isAndroid) { - final priorityDays = pendingDays - .where( - (day) => day.bundle != null && day.bundle!['skipped'] != true, - ) - .map((day) => MapEntry(day.date, day.bundle!)) - .toList(); - Future recordPriorityFailure(String date) async { - final entry = (retryState[date] as Map?)?.cast(); - retryState[date] = { - 'attempts': ((entry?['attempts'] as num?)?.toInt() ?? 0) + 1, - 'last_ms': DateTime.now().millisecondsSinceEpoch, - 'finalized': pendingDays - .firstWhere((pending) => pending.date == date) - .finalized, - }; - await LocalDb.setCursor(_kRetryCursor, jsonEncode(retryState)); - } + late final Future Function(String? androidSleepAlreadyWritten) + exportBulk; + Future exportPriorityOrBulk() async { + if (Platform.isAndroid) { + final priorityDays = pendingDays + .where( + (day) => day.bundle != null && day.bundle!['skipped'] != true, + ) + .map((day) => MapEntry(day.date, day.bundle!)) + .toList(); + MapEntry>? priorityDay; + for (final day in priorityDays) { + if (normalizeHealthSleepSession(day.value) != null) { + priorityDay = day; + break; + } + } + if (priorityDay != null) { + final pendingPriorityDay = pendingDays.firstWhere( + (pending) => pending.date == priorityDay!.key, + ); + final entry = (retryState[priorityDay.key] as Map?) + ?.cast(); + var attempts = (entry?['attempts'] as num?)?.toInt() ?? 0; + var lastAttemptMs = (entry?['last_ms'] as num?)?.toInt(); + final wasFinalized = entry?['finalized'] as bool? ?? false; + if (pendingPriorityDay.finalized && !wasFinalized && attempts > 0) { + attempts = 0; + lastAttemptMs = null; + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + final shouldAttempt = shouldAttemptHealthExport( + attempts: attempts, + maxAttempts: _kMaxExportAttempts, + now: DateTime.fromMillisecondsSinceEpoch(nowMs), + lastAttempt: lastAttemptMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), + backoff: _backoffFor(attempts), + force: forceRetry, + ); + if (!shouldAttempt) { + if (attempts >= _kMaxExportAttempts) return exportBulk(null); + return 0; + } + Future recordPriorityFailure() async { + retryState[priorityDay!.key] = { + 'attempts': attempts + 1, + 'last_ms': nowMs, + 'finalized': pendingPriorityDay.finalized, + }; + await LocalDb.setCursor(_kRetryCursor, jsonEncode(retryState)); + } - String? priorityDate; - try { - priorityResult = await exportNewestPrioritySleep( - newestFirstDays: priorityDays, - write: (bundle) { - priorityDate = priorityDays - .firstWhere((day) => identical(day.value, bundle)) - .key; - return _androidSleep.replace(bundle); - }, - ); - } catch (e) { - debugPrint('[health] write priority Android sleep session: $e'); - final failedPriorityDate = priorityDate; - if (failedPriorityDate != null) { - await recordPriorityFailure(failedPriorityDate); + var bulkDone = 0; + try { + final priorityResult = await exportPrioritySleepBeforeBulk( + newestFirstDays: priorityDays, + write: _androidSleep.replace, + exportBulk: (androidSleepAlreadyWritten) async { + if (entry != null) { + retryState.remove(priorityDay!.key); + retryStateDirty = true; + } + bulkDone = await exportBulk(androidSleepAlreadyWritten); + }, + ); + if (!priorityResult.succeeded) { + await recordPriorityFailure(); + return 0; + } + return bulkDone; + } catch (e) { + debugPrint('[health] write priority Android sleep session: $e'); + await recordPriorityFailure(); + return 0; + } } - return 0; - } - if (!priorityResult.succeeded) { - await recordPriorityFailure(priorityResult.date!); - return 0; } + return exportBulk(null); } - var done = 0; - var newCursor = cursor; - var prefixContiguous = true; // still extending the finalized prefix? - for (final day in pendingDays.reversed) { - final date = day.date; - final finalized = day.finalized; - final bundle = day.bundle; - if (bundle == null || bundle['skipped'] == true) { - if (!finalized) prefixContiguous = false; - continue; - } + exportBulk = (String? androidSleepAlreadyWritten) async { + var done = 0; + var newCursor = cursor; + var prefixContiguous = true; // still extending the finalized prefix? + for (final day in pendingDays.reversed) { + final date = day.date; + final finalized = day.finalized; + final bundle = day.bundle; + if (bundle == null || bundle['skipped'] == true) { + if (!finalized) prefixContiguous = false; + continue; + } - final entry = (retryState[date] as Map?)?.cast(); - var attempts = (entry?['attempts'] as num?)?.toInt() ?? 0; - var lastAttemptMs = (entry?['last_ms'] as num?)?.toInt(); - final wasFinalized = entry?['finalized'] as bool? ?? false; - if (finalized && !wasFinalized && attempts > 0) { - // The day just transitioned non-finalized -> finalized: a - // materially different (complete, now-immutable) payload than - // whatever was still re-deriving during the "recent tail" attempts - // that accrued this cap/backoff. Give it a clean attempt budget so - // a newly-finalized day is never skipped because of a cap earned - // against the old mutable version. - attempts = 0; - lastAttemptMs = null; - } - final nowMs = DateTime.now().millisecondsSinceEpoch; - - var ok = false; - var giveUp = false; - final shouldAttempt = shouldAttemptHealthExport( - attempts: attempts, - maxAttempts: _kMaxExportAttempts, - now: DateTime.fromMillisecondsSinceEpoch(nowMs), - lastAttempt: lastAttemptMs == null - ? null - : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), - backoff: _backoffFor(attempts), - force: forceRetry, - ); - if (!shouldAttempt && attempts >= _kMaxExportAttempts) { - giveUp = true; - } else if (!shouldAttempt) { - // Not due for retry yet — don't hammer the health store on every - // drain/derive pass; counts as "not done" for the cursor below. - } else { - ok = await _exportDay( - date, - bundle, - androidSleepAlreadyWritten: date == priorityResult.date, - ); // delete-then-write (idempotent) - if (ok) { - if (entry != null) { - retryState.remove(date); - retryStateDirty = true; - } + final entry = (retryState[date] as Map?)?.cast(); + var attempts = (entry?['attempts'] as num?)?.toInt() ?? 0; + var lastAttemptMs = (entry?['last_ms'] as num?)?.toInt(); + final wasFinalized = entry?['finalized'] as bool? ?? false; + if (finalized && !wasFinalized && attempts > 0) { + // The day just transitioned non-finalized -> finalized: a + // materially different (complete, now-immutable) payload than + // whatever was still re-deriving during the "recent tail" attempts + // that accrued this cap/backoff. Give it a clean attempt budget so + // a newly-finalized day is never skipped because of a cap earned + // against the old mutable version. + attempts = 0; + lastAttemptMs = null; + } + final nowMs = DateTime.now().millisecondsSinceEpoch; + + var ok = false; + var giveUp = false; + final shouldAttempt = shouldAttemptHealthExport( + attempts: attempts, + maxAttempts: _kMaxExportAttempts, + now: DateTime.fromMillisecondsSinceEpoch(nowMs), + lastAttempt: lastAttemptMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), + backoff: _backoffFor(attempts), + force: forceRetry, + ); + if (!shouldAttempt && attempts >= _kMaxExportAttempts) { + giveUp = true; + } else if (!shouldAttempt) { + // Not due for retry yet — don't hammer the health store on every + // drain/derive pass; counts as "not done" for the cursor below. } else { - final nextAttempts = attempts + 1; - retryState[date] = { - 'attempts': nextAttempts, - 'last_ms': nowMs, - 'finalized': finalized, - }; - retryStateDirty = true; - debugPrint( - '[health] day $date export incomplete (attempt $nextAttempts/$_kMaxExportAttempts)', - ); - if (nextAttempts >= _kMaxExportAttempts) { + ok = await _exportDay( + date, + bundle, + androidSleepAlreadyWritten: date == androidSleepAlreadyWritten, + ); // delete-then-write (idempotent) + if (ok) { + if (entry != null) { + retryState.remove(date); + retryStateDirty = true; + } + } else { + final nextAttempts = attempts + 1; + retryState[date] = { + 'attempts': nextAttempts, + 'last_ms': nowMs, + 'finalized': finalized, + }; + retryStateDirty = true; debugPrint( - '[health] day $date exceeded $_kMaxExportAttempts export attempts — giving up, will stop blocking newer days', + '[health] day $date export incomplete (attempt $nextAttempts/$_kMaxExportAttempts)', ); + if (nextAttempts >= _kMaxExportAttempts) { + debugPrint( + '[health] day $date exceeded $_kMaxExportAttempts export attempts — giving up, will stop blocking newer days', + ); + } } } - } - if (ok) { - done++; - onProgress?.call(done); + if (ok) { + done++; + onProgress?.call(done); + } + // Advance the cursor only while the finalized prefix stays unbroken — + // a non-finalized day, a still-backing-off retry, or a day still + // under the attempt cap all stop it (re-checked next pass); a + // given-up day counts alongside a genuine success so it can't wedge + // every later day's cursor forever. + if (prefixContiguous && finalized && (ok || giveUp)) { + newCursor = date; + } else { + prefixContiguous = false; + } } - // Advance the cursor only while the finalized prefix stays unbroken — - // a non-finalized day, a still-backing-off retry, or a day still - // under the attempt cap all stop it (re-checked next pass); a - // given-up day counts alongside a genuine success so it can't wedge - // every later day's cursor forever. - if (prefixContiguous && finalized && (ok || giveUp)) { - newCursor = date; - } else { - prefixContiguous = false; + if (newCursor != cursor) { + await LocalDb.setCursor('health_export_through', newCursor); } - } - if (newCursor != cursor) { - await LocalDb.setCursor('health_export_through', newCursor); - } - if (retryStateDirty) { - await LocalDb.setCursor(_kRetryCursor, jsonEncode(retryState)); - } - debugPrint('[health] exported $done day(s); finalized-cursor=$newCursor'); - return done; + if (retryStateDirty) { + await LocalDb.setCursor(_kRetryCursor, jsonEncode(retryState)); + } + debugPrint( + '[health] exported $done day(s); finalized-cursor=$newCursor', + ); + return done; + }; + + return exportPriorityOrBulk(); } catch (e) { debugPrint('[health] exportAll: $e'); return 0; diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 94806fe4..848f9589 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -94,33 +94,69 @@ void main() { expect(writes, 1); }); + test('failed priority sleep never invokes the bulk callback', () async { + var priorityWrites = 0; + var bulkWrites = 0; + + final result = await exportPrioritySleepBeforeBulk( + newestFirstDays: [MapEntry('2026-08-05', _overnightBundle())], + write: (bundle) async { + priorityWrites++; + return false; + }, + exportBulk: (androidSleepAlreadyWritten) async { + bulkWrites++; + }, + ); + + expect(result.date, '2026-08-05'); + expect(result.succeeded, isFalse); + expect(priorityWrites, 1); + expect(bulkWrites, 0); + }); + + test('thrown priority sleep never invokes the bulk callback', () async { + var bulkWrites = 0; + + await expectLater( + exportPrioritySleepBeforeBulk( + newestFirstDays: [MapEntry('2026-08-05', _overnightBundle())], + write: (bundle) async => throw StateError('priority write failed'), + exportBulk: (androidSleepAlreadyWritten) async { + bulkWrites++; + }, + ), + throwsA(isA()), + ); + + expect(bulkWrites, 0); + }); + test( - 'failed priority sleep stops bulk export and avoids duplicate sleep', - () { - final source = File('lib/health/health_export.dart').readAsStringSync(); - final exportAll = source.substring( - source.indexOf('Future exportAll'), - ); - final priority = exportAll.indexOf('exportNewestPrioritySleep('); - final bulkLoop = exportAll.indexOf( - 'for (final day in pendingDays.reversed)', - ); + 'successful priority sleep invokes bulk once without another sleep', + () async { + var priorityWrites = 0; + var totalSleepWrites = 0; + final bulkPriorityDates = []; - expect(priority, greaterThanOrEqualTo(0)); - expect(bulkLoop, greaterThan(priority)); - expect( - exportAll.indexOf('if (!priorityResult.succeeded)', priority), - greaterThan(priority), - ); - expect(exportAll.indexOf('return 0;', priority), greaterThan(priority)); - expect( - source, - contains('androidSleepAlreadyWritten: date == priorityResult.date'), - ); - expect( - source, - contains('if (Platform.isAndroid && !androidSleepAlreadyWritten)'), + final result = await exportPrioritySleepBeforeBulk( + newestFirstDays: [MapEntry('2026-08-05', _overnightBundle())], + write: (bundle) async { + priorityWrites++; + totalSleepWrites++; + return true; + }, + exportBulk: (androidSleepAlreadyWritten) async { + bulkPriorityDates.add(androidSleepAlreadyWritten); + if (androidSleepAlreadyWritten == null) totalSleepWrites++; + }, ); + + expect(result.date, '2026-08-05'); + expect(result.succeeded, isTrue); + expect(priorityWrites, 1); + expect(totalSleepWrites, 1); + expect(bulkPriorityDates, ['2026-08-05']); }, ); From 2d7f96d463abfc7568f15be5d713be23e25ae3de Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 13:06:04 +0200 Subject: [PATCH 10/19] fix(health): retain retries through priority sleep --- lib/health/health_export.dart | 4 ---- test/health_sleep_export_test.dart | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index a09b62a6..16f0ad36 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -403,10 +403,6 @@ class HealthExporter { newestFirstDays: priorityDays, write: _androidSleep.replace, exportBulk: (androidSleepAlreadyWritten) async { - if (entry != null) { - retryState.remove(priorityDay!.key); - retryStateDirty = true; - } bulkDone = await exportBulk(androidSleepAlreadyWritten); }, ); diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 848f9589..4592c297 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -132,6 +132,22 @@ void main() { expect(bulkWrites, 0); }); + test('priority success leaves shared retry state for the bulk export', () { + final source = File('lib/health/health_export.dart').readAsStringSync(); + final priorityCallback = source.substring( + source.indexOf( + 'final priorityResult = await exportPrioritySleepBeforeBulk(', + ), + source.indexOf('exportBulk = (String? androidSleepAlreadyWritten)'), + ); + + expect( + priorityCallback, + isNot(contains('retryState.remove(priorityDay!.key)')), + reason: 'only the full-day bulk result may clear a shared retry entry', + ); + }); + test( 'successful priority sleep invokes bulk once without another sleep', () async { From 4a76d8667b2da1440c108e991f5544691f638ca9 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 13:22:22 +0200 Subject: [PATCH 11/19] fix(health): batch Android heart-rate export --- .../HealthConnectHeartRateWriter.kt | 118 +++++++++++ .../openstrap_edge/NativeChannels.kt | 1 + lib/health/health_export.dart | 43 ++-- lib/health/health_heart_rate_batch.dart | 119 +++++++++++ test/health_heart_rate_export_test.dart | 192 ++++++++++++++++++ 5 files changed, 452 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt create mode 100644 lib/health/health_heart_rate_batch.dart create mode 100644 test/health_heart_rate_export_test.dart diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt new file mode 100644 index 00000000..d7cbb3f3 --- /dev/null +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt @@ -0,0 +1,118 @@ +package wtf.openstrap.openstrap_edge + +import android.content.Context +import android.util.Log +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.time.TimeRangeFilter +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Replaces one day's normalized minute heart-rate samples in Health Connect. */ +object HealthConnectHeartRateWriter { + private const val TAG = "OpenStrapHeartRateExport" + private const val CHANNEL = "openstrap/health_connect_heart_rate" + private const val REPLACE_HEART_RATE_DAY = "replaceHeartRateDay" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val replaceMutex = Mutex() + + fun register(engine: FlutterEngine, context: Context) { + val app = context.applicationContext + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + if (call.method != REPLACE_HEART_RATE_DAY) { + result.notImplemented() + return@setMethodCallHandler + } + scope.launch { + val replaced = withContext(Dispatchers.IO) { replace(app, call) } + result.success(replaced) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun replace(context: Context, call: MethodCall): Boolean { + return replaceMutex.withLock { + try { + if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { + false + } else { + val request = buildRequest(call) ?: return@withLock false + if (request.samples.isEmpty()) return@withLock true + + val client = HealthConnectClient.getOrCreate(context) + client.deleteRecords( + HeartRateRecord::class, + TimeRangeFilter.between(request.start, request.end), + ) + client.insertRecords( + listOf( + HeartRateRecord( + startTime = request.start, + endTime = request.end, + startZoneOffset = ZoneId.systemDefault().rules.getOffset(request.start), + endZoneOffset = ZoneId.systemDefault().rules.getOffset(request.end), + samples = request.samples, + metadata = Metadata( + recordingMethod = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, + ), + ), + ), + ).recordIdsList.size == 1 + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Log.e(TAG, "HeartRateRecord replace failed", error) + false + } + } + } + + private data class Request( + val start: Instant, + val end: Instant, + val samples: List, + ) + + private fun buildRequest(call: MethodCall): Request? { + val start = (call.argument("startTime") as? Number) + ?.toLong()?.let(Instant::ofEpochMilli) ?: return null + val end = (call.argument("endTime") as? Number) + ?.toLong()?.let(Instant::ofEpochMilli) ?: return null + if (!start.isBefore(end)) return null + + val rawSamples = call.argument>("samples") ?: return null + val samples = ArrayList(rawSamples.size) + var previous: Instant? = null + for (raw in rawSamples) { + val sample = (raw as? Map<*, *>)?.let(::buildSample) ?: return null + if (sample.time.isBefore(start) || !sample.time.isBefore(end)) return null + if (previous != null && !previous.isBefore(sample.time)) return null + samples.add(sample) + previous = sample.time + } + return Request(start, end, samples) + } + + private fun buildSample(raw: Map<*, *>): HeartRateRecord.Sample? { + val time = (raw["time"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) + ?: return null + val beatsPerMinute = (raw["beatsPerMinute"] as? Number)?.toLong() ?: return null + if (beatsPerMinute !in 1..300) return null + return HeartRateRecord.Sample(time, beatsPerMinute) + } +} diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt index b805ee3f..2b982772 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -51,6 +51,7 @@ object NativeChannels { val app = context.applicationContext HealthConnectSleepWriter.register(engine, app) + HealthConnectHeartRateWriter.register(engine, app) MethodChannel(engine.dartExecutor.binaryMessenger, EDGE_TRACKING_CHANNEL) .setMethodCallHandler { call, result -> diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 16f0ad36..92405409 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -25,6 +25,7 @@ import 'package:flutter/foundation.dart'; import 'package:health/health.dart'; import '../data/db.dart'; +import 'health_heart_rate_batch.dart'; import 'health_sleep_session.dart'; /// What we can do with the health store right now. @@ -136,8 +137,13 @@ class HealthExporter { final _androidSleep = HealthConnectSleepSessionExporter( writer: MethodChannelHealthConnectSleepSessionWriter(), ); + final HealthConnectHeartRateWriter _androidHeartRate; bool _configured = false; + HealthExporter({HealthConnectHeartRateWriter? androidHeartRate}) + : _androidHeartRate = + androidHeartRate ?? MethodChannelHealthConnectHeartRateWriter(); + /// True on iOS/macOS (Apple Health); false on Android (Health Connect). static bool get isApple => Platform.isIOS || Platform.isMacOS; @@ -727,27 +733,22 @@ class HealthExporter { debugPrint('[health] query continuous hr: $e'); success = false; } - if (hrRows != null) { - for (final r in hrRows) { - final minuteTs = (r['minute_ts'] as num).toInt(); - final avgHr = (r['avg_hr'] as num).toDouble(); - if (avgHr > 0) { - final t = DateTime.fromMillisecondsSinceEpoch(minuteTs * 1000); - try { - final wrote = await _health.writeHealthData( - value: avgHr, - type: HealthDataType.HEART_RATE, - startTime: t, - endTime: t.add(const Duration(minutes: 1)), - unit: HealthDataUnit.BEATS_PER_MINUTE, - ); - if (!wrote) success = false; - } catch (e) { - debugPrint('[health] write continuous hr @$minuteTs: $e'); - success = false; - } - } - } + if (hrRows != null && + !await exportContinuousHeartRateDay( + rows: hrRows, + start: dayStart, + end: dayEnd, + useAndroidBatch: Platform.isAndroid, + androidWriter: _androidHeartRate, + writeGeneric: (sample, sampleEnd) => _health.writeHealthData( + value: sample.beatsPerMinute.toDouble(), + type: HealthDataType.HEART_RATE, + startTime: sample.time, + endTime: sampleEnd, + unit: HealthDataUnit.BEATS_PER_MINUTE, + ), + )) { + success = false; } // Steps (24/7 estimate) over the whole day. diff --git a/lib/health/health_heart_rate_batch.dart b/lib/health/health_heart_rate_batch.dart new file mode 100644 index 00000000..840c55e3 --- /dev/null +++ b/lib/health/health_heart_rate_batch.dart @@ -0,0 +1,119 @@ +import 'package:flutter/services.dart'; + +class HealthHeartRateSample { + const HealthHeartRateSample(this.time, this.beatsPerMinute); + + final DateTime time; + final int beatsPerMinute; + + Map toMap() => { + 'time': time.millisecondsSinceEpoch, + 'beatsPerMinute': beatsPerMinute, + }; + + @override + bool operator ==(Object other) => + other is HealthHeartRateSample && + time == other.time && + beatsPerMinute == other.beatsPerMinute; + + @override + int get hashCode => Object.hash(time, beatsPerMinute); +} + +abstract interface class HealthConnectHeartRateWriter { + Future replaceDay( + DateTime start, + DateTime end, + List samples, + ); +} + +class MethodChannelHealthConnectHeartRateWriter + implements HealthConnectHeartRateWriter { + MethodChannelHealthConnectHeartRateWriter({MethodChannel? channel}) + : _channel = + channel ?? const MethodChannel('openstrap/health_connect_heart_rate'); + + final MethodChannel _channel; + + @override + Future replaceDay( + DateTime start, + DateTime end, + List samples, + ) async { + final wrote = await _channel.invokeMethod('replaceHeartRateDay', { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + 'samples': samples.map((sample) => sample.toMap()).toList(), + }); + return wrote == true; + } +} + +List normalizeHealthHeartRateSamples( + List> rows, + DateTime start, + DateTime end, +) { + final samples = <({HealthHeartRateSample sample, int index})>[]; + for (var index = 0; index < rows.length; index++) { + final row = rows[index]; + final seconds = (row['minute_ts'] as num?)?.toInt(); + final bpm = (row['avg_hr'] as num?)?.toInt(); + if (seconds == null || bpm == null || bpm < 1 || bpm > 300) continue; + final time = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); + if (time.isBefore(start) || !time.isBefore(end)) continue; + samples.add((sample: HealthHeartRateSample(time, bpm), index: index)); + } + samples.sort((left, right) { + final timeOrder = left.sample.time.compareTo(right.sample.time); + return timeOrder != 0 ? timeOrder : left.index.compareTo(right.index); + }); + + final unique = []; + DateTime? previousTime; + for (final entry in samples) { + if (entry.sample.time == previousTime) continue; + unique.add(entry.sample); + previousTime = entry.sample.time; + } + return unique; +} + +Future exportContinuousHeartRateDay({ + required List> rows, + required DateTime start, + required DateTime end, + required bool useAndroidBatch, + required HealthConnectHeartRateWriter androidWriter, + required Future Function(HealthHeartRateSample sample, DateTime end) + writeGeneric, +}) async { + final samples = normalizeHealthHeartRateSamples(rows, start, end); + if (samples.isEmpty) return true; + + if (useAndroidBatch) { + try { + return await androidWriter.replaceDay(start, end, samples); + } catch (_) { + return false; + } + } + + var success = true; + for (final sample in samples) { + try { + if (!await writeGeneric( + sample, + sample.time.add(const Duration(minutes: 1)), + )) { + success = false; + } + } catch (_) { + success = false; + } + } + return success; +} diff --git a/test/health_heart_rate_export_test.dart b/test/health_heart_rate_export_test.dart new file mode 100644 index 00000000..16c59191 --- /dev/null +++ b/test/health_heart_rate_export_test.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/health/health_heart_rate_batch.dart'; + +int _seconds(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; + +Map _row(DateTime time, num bpm) => { + 'minute_ts': _seconds(time), + 'avg_hr': bpm, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('continuous heart-rate export', () { + final start = DateTime(2026, 8, 5); + final end = DateTime(2026, 8, 6); + + test('normalizes ordered unique in-range whole-minute samples', () { + final samples = normalizeHealthHeartRateSamples( + [ + _row(start.add(const Duration(minutes: 3)), 73.9), + _row(start.subtract(const Duration(minutes: 1)), 65), + _row(start.add(const Duration(minutes: 1)), 72.8), + _row(start.add(const Duration(minutes: 3)), 74), + _row(start.add(const Duration(minutes: 2)), 0), + _row(start.add(const Duration(minutes: 4)), 301), + _row(end, 69), + ], + start, + end, + ); + + expect(samples, hasLength(2)); + expect(samples.map((sample) => sample.time), [ + DateTime(2026, 8, 5, 0, 1), + DateTime(2026, 8, 5, 0, 3), + ]); + expect(samples[0].beatsPerMinute, 72); + expect(samples[1].beatsPerMinute, anyOf(73, 74)); + }); + + test('Android sends all normalized samples in one replace call', () async { + const channel = MethodChannel('openstrap/test_health_connect_heart_rate'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final wrote = await exportContinuousHeartRateDay( + rows: [ + _row(start.add(const Duration(minutes: 2)), 81.9), + _row(start.add(const Duration(minutes: 1)), 80.1), + _row(start.add(const Duration(minutes: 2)), 82), + ], + start: start, + end: end, + useAndroidBatch: true, + androidWriter: MethodChannelHealthConnectHeartRateWriter( + channel: channel, + ), + writeGeneric: (_, __) async => throw StateError('not Apple'), + ); + + expect(wrote, isTrue); + expect(calls, hasLength(1)); + expect(calls.single.method, 'replaceHeartRateDay'); + final args = (calls.single.arguments as Map).cast(); + expect(args['startTime'], start.millisecondsSinceEpoch); + expect(args['endTime'], end.millisecondsSinceEpoch); + expect(args['samples'], [ + { + 'time': DateTime(2026, 8, 5, 0, 1).millisecondsSinceEpoch, + 'beatsPerMinute': 80, + }, + { + 'time': DateTime(2026, 8, 5, 0, 2).millisecondsSinceEpoch, + 'beatsPerMinute': anyOf(81, 82), + }, + ]); + }); + + test('Android batch false result is retryable', () async { + const channel = MethodChannel( + 'openstrap/test_health_connect_heart_rate_failure', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => false); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + expect( + await exportContinuousHeartRateDay( + rows: [_row(start, 70)], + start: start, + end: end, + useAndroidBatch: true, + androidWriter: MethodChannelHealthConnectHeartRateWriter( + channel: channel, + ), + writeGeneric: (_, __) async => true, + ), + isFalse, + ); + }); + + test( + 'Apple writes each valid minute and aggregates false results', + () async { + final writes = <(HealthHeartRateSample, DateTime)>[]; + + final wrote = await exportContinuousHeartRateDay( + rows: [ + _row(start.add(const Duration(minutes: 2)), 90.5), + _row(start.add(const Duration(minutes: 1)), 80.9), + _row(start.add(const Duration(minutes: 3)), 0), + ], + start: start, + end: end, + useAndroidBatch: false, + androidWriter: _UnusedHeartRateWriter(), + writeGeneric: (sample, sampleEnd) async { + writes.add((sample, sampleEnd)); + return sample.beatsPerMinute != 80; + }, + ); + + expect(wrote, isFalse); + expect(writes, [ + ( + HealthHeartRateSample(DateTime(2026, 8, 5, 0, 1), 80), + DateTime(2026, 8, 5, 0, 2), + ), + ( + HealthHeartRateSample(DateTime(2026, 8, 5, 0, 2), 90), + DateTime(2026, 8, 5, 0, 3), + ), + ]); + }, + ); + + test('empty normalized input succeeds without either writer', () async { + var genericCalls = 0; + final androidWriter = _UnusedHeartRateWriter(); + + expect( + await exportContinuousHeartRateDay( + rows: [ + _row(start.subtract(const Duration(minutes: 1)), 70), + _row(start, 0), + _row(end, 70), + ], + start: start, + end: end, + useAndroidBatch: true, + androidWriter: androidWriter, + writeGeneric: (_, __) async { + genericCalls++; + return true; + }, + ), + isTrue, + ); + expect(androidWriter.calls, 0); + expect(genericCalls, 0); + }); + }); +} + +class _UnusedHeartRateWriter implements HealthConnectHeartRateWriter { + var calls = 0; + + @override + Future replaceDay( + DateTime start, + DateTime end, + List samples, + ) async { + calls++; + return true; + } +} From c7b3145e8eda9b916bf3eb312f626730b10b1555 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 13:29:01 +0200 Subject: [PATCH 12/19] fix(health): avoid duplicate Android heart-rate cleanup --- .../HealthConnectHeartRateWriter.kt | 5 ++--- lib/health/health_export.dart | 8 +++++++- test/health_heart_rate_export_test.dart | 20 +++++++++++++++++++ test/health_sleep_export_test.dart | 12 +++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt index d7cbb3f3..12bf6e29 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt @@ -47,12 +47,11 @@ object HealthConnectHeartRateWriter { private suspend fun replace(context: Context, call: MethodCall): Boolean { return replaceMutex.withLock { try { + val request = buildRequest(call) ?: return@withLock false + if (request.samples.isEmpty()) return@withLock true if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { false } else { - val request = buildRequest(call) ?: return@withLock false - if (request.samples.isEmpty()) return@withLock true - val client = HealthConnectClient.getOrCreate(context) client.deleteRecords( HeartRateRecord::class, diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 92405409..cb72e987 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -62,7 +62,13 @@ List healthDeleteTypes({required bool isApplePlatform}) { ]; return isApplePlatform ? types - : types.where((type) => !_sleepHealthTypes.contains(type)).toList(); + : types + .where( + (type) => + !_sleepHealthTypes.contains(type) && + type != HealthDataType.HEART_RATE, + ) + .toList(); } bool shouldAttemptHealthExport({ diff --git a/test/health_heart_rate_export_test.dart b/test/health_heart_rate_export_test.dart index 16c59191..39c42f6b 100644 --- a/test/health_heart_rate_export_test.dart +++ b/test/health_heart_rate_export_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -174,6 +175,25 @@ void main() { expect(androidWriter.calls, 0); expect(genericCalls, 0); }); + + test('native empty request completes before Health Connect availability', () { + final source = File( + 'android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt', + ).readAsStringSync(); + final replace = source.substring( + source.indexOf('private suspend fun replace'), + source.indexOf('private data class Request'), + ); + + expect( + replace.indexOf('val request = buildRequest(call)'), + lessThan(replace.indexOf('HealthConnectClient.getSdkStatus(context)')), + ); + expect( + replace.indexOf('if (request.samples.isEmpty()) return@withLock true'), + lessThan(replace.indexOf('HealthConnectClient.getSdkStatus(context)')), + ); + }); }); } diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 4592c297..14eb6e02 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -180,6 +180,11 @@ void main() { final types = healthDeleteTypes(isApplePlatform: false); expect(types, contains(HealthDataType.STEPS)); + expect( + types, + isNot(contains(HealthDataType.HEART_RATE)), + reason: 'Android native replacement owns heart-rate cleanup', + ); expect( types, isNot( @@ -195,6 +200,13 @@ void main() { expect(types.where((type) => type.name.startsWith('SLEEP_')), isEmpty); }); + test('Apple generic cleanup retains heart-rate records', () { + expect( + healthDeleteTypes(isApplePlatform: true), + contains(HealthDataType.HEART_RATE), + ); + }); + test('Apple and Android share one hypnogram stage vocabulary', () { expect(healthSleepStageOf('wake'), HealthSleepStage.awake); expect(healthSleepStageOf('awake'), HealthSleepStage.awake); From c8d0c77d40be2890f8043434466831d0a0a18f8a Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 13:37:56 +0200 Subject: [PATCH 13/19] test(health): satisfy heart-rate export analyzer --- test/health_heart_rate_export_test.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/health_heart_rate_export_test.dart b/test/health_heart_rate_export_test.dart index 39c42f6b..6340e2fe 100644 --- a/test/health_heart_rate_export_test.dart +++ b/test/health_heart_rate_export_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -68,7 +67,7 @@ void main() { androidWriter: MethodChannelHealthConnectHeartRateWriter( channel: channel, ), - writeGeneric: (_, __) async => throw StateError('not Apple'), + writeGeneric: (_, _) async => throw StateError('not Apple'), ); expect(wrote, isTrue); @@ -109,7 +108,7 @@ void main() { androidWriter: MethodChannelHealthConnectHeartRateWriter( channel: channel, ), - writeGeneric: (_, __) async => true, + writeGeneric: (_, _) async => true, ), isFalse, ); @@ -165,7 +164,7 @@ void main() { end: end, useAndroidBatch: true, androidWriter: androidWriter, - writeGeneric: (_, __) async { + writeGeneric: (_, _) async { genericCalls++; return true; }, From 3b9914de3ab28ee454a83d13af11e72dda23fd6b Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 14:27:35 +0200 Subject: [PATCH 14/19] docs(health): specify legacy sleep cleanup window --- ...lth-connect-legacy-sleep-cleanup-design.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-health-connect-legacy-sleep-cleanup-design.md diff --git a/docs/superpowers/specs/2026-08-06-health-connect-legacy-sleep-cleanup-design.md b/docs/superpowers/specs/2026-08-06-health-connect-legacy-sleep-cleanup-design.md new file mode 100644 index 00000000..de724cd9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-health-connect-legacy-sleep-cleanup-design.md @@ -0,0 +1,64 @@ +# Health Connect Legacy Sleep Cleanup Design + +## Problem + +The dedicated Android writer correctly inserts one `SleepSessionRecord` with +all normalized stages, but its replacement deletion is limited to the new +session's exact start and end times. A legacy one-stage record can begin before +that interval and overlap it. For example, a stale `23:34–02:55` fragment is +not removed when the recomputed session is `01:36–08:20`. Health Connect keeps +both records, and consumers such as Google Health can continue to select the +legacy fragmented night. + +A Google account change does not remove these records because Health Connect +storage is local to the Android device. + +## Chosen design + +For Android sleep replacement, derive a local sleep-day cleanup interval that +runs from noon to noon and fully contains the new parent session. Delete +OpenStrap-owned `SleepSessionRecord` values in that interval, then insert the +single normalized parent session. + +For a normal overnight session ending before local noon, the interval is local +noon on the preceding calendar day through local noon on the end date. If the +session ends at or after noon, the upper boundary moves to the following local +noon. If an unusually long session starts before the calculated lower boundary, +the lower boundary is extended to the session start so the parent itself is +always covered. + +The boundaries are calculated with `ZoneId.systemDefault()` and local calendar +dates rather than adding a fixed 24 hours, so DST transitions remain correct. +Health Connect automatically restricts app-initiated deletion to records owned +by the calling package; sleep records written by other apps are not affected. + +## Data flow + +1. Dart normalizes and validates the parent session and stages as today. +2. The typed MethodChannel sends the same parent payload. +3. The Android writer calculates the local noon-to-noon cleanup interval. +4. It deletes OpenStrap-owned `SleepSessionRecord` values in that interval. +5. It inserts exactly one parent with all ordered, clipped, positive-duration, + non-overlapping stages. +6. Any delete or insert exception, or an insert count other than one, returns + `false` and preserves the existing retry/failure behavior. + +Apple Health remains unchanged. + +## Testing + +Add a regression test for a recomputed `01:36–08:20` session with a stale +legacy record beginning at `23:34`. The test must first fail because the current +exact-session cleanup starts at `01:36`, then pass with cleanup boundaries that +include `23:34` while still containing the new parent. + +Existing tests continue to verify one parent call, stage normalization, +midnight crossing, serialization, idempotent replacement, and false-result +propagation. Run formatting, focused health tests, `flutter analyze`, the full +test suite, and an Android release build before device installation. + +## Scope + +Only the Android typed sleep-session cleanup and its regression coverage change. +No database, analytics, BLE, Apple Health, Google Health, or unrelated export +behavior is modified. From 581da816e2547e47616e8dfc4d1328fb0b87437f Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 14:29:49 +0200 Subject: [PATCH 15/19] docs(health): plan legacy sleep cleanup --- ...-06-health-connect-legacy-sleep-cleanup.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-health-connect-legacy-sleep-cleanup.md diff --git a/docs/superpowers/plans/2026-08-06-health-connect-legacy-sleep-cleanup.md b/docs/superpowers/plans/2026-08-06-health-connect-legacy-sleep-cleanup.md new file mode 100644 index 00000000..d86c5370 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-health-connect-legacy-sleep-cleanup.md @@ -0,0 +1,228 @@ +# Health Connect Legacy Sleep Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove legacy OpenStrap sleep fragments that begin before a recomputed parent session, then write exactly one Android `SleepSessionRecord` for the night. + +**Architecture:** Keep the typed Dart sleep-session API and normalized parent payload unchanged. Add a pure Kotlin helper that derives a DST-safe local noon-to-noon cleanup interval containing the parent; the Android writer uses that broader interval only for deletion and still inserts the exact normalized parent. + +**Tech Stack:** Flutter/Dart, Kotlin/JVM 17, AndroidX Health Connect `1.1.0-alpha07`, JUnit 4, Gradle. + +## Global Constraints + +- Only OpenStrap-owned `SleepSessionRecord` values may be deleted; Health Connect enforces calling-package ownership. +- Preserve the exact parent boundaries and every normalized awake, REM, light, and deep stage. +- Preserve Apple Health behavior unchanged. +- Use local calendar noon boundaries through `ZoneId`, never a fixed 24-hour duration, so DST transitions remain correct. +- A native delete or insert failure must return `false` to the Dart retry policy. +- Do not modify database, analytics, BLE, Google Health, or unrelated export behavior. + +--- + +### Task 1: DST-safe legacy sleep cleanup window + +**Files:** +- Modify: `android/app/build.gradle.kts` +- Modify: `android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt` +- Create: `android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt` + +**Interfaces:** +- Consumes: `SleepSessionRecord.startTime`, `SleepSessionRecord.endTime`, and `ZoneId.systemDefault()`. +- Produces: `internal data class SleepCleanupRange(val start: Instant, val end: Instant)` and `internal fun sleepCleanupRange(start: Instant, end: Instant, zoneId: ZoneId): SleepCleanupRange`. + +- [ ] **Step 1: Add the JUnit test dependency and write the failing legacy-fragment test** + +Add this dependency to `android/app/build.gradle.kts`: + +```kotlin +testImplementation("junit:junit:4.13.2") +``` + +Create `HealthConnectSleepWriterTest.kt` with a test that constructs: + +```kotlin +private val berlin = ZoneId.of("Europe/Berlin") + +private fun localInstant(year: Int, month: Int, day: Int, hour: Int, minute: Int): Instant = + ZonedDateTime.of(year, month, day, hour, minute, 0, 0, berlin).toInstant() + +@Test +fun cleanupRangeIncludesLegacyFragmentThatStartsBeforeRecomputedSession() { + val sessionStart = localInstant(2026, 8, 6, 1, 36) + val sessionEnd = localInstant(2026, 8, 6, 8, 20) + val staleFragmentStart = localInstant(2026, 8, 5, 23, 34) + + val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) + + assertEquals(localInstant(2026, 8, 5, 12, 0), range.start) + assertEquals(localInstant(2026, 8, 6, 12, 0), range.end) + assertTrue(!staleFragmentStart.isBefore(range.start)) + assertTrue(staleFragmentStart.isBefore(range.end)) +} +``` + +- [ ] **Step 2: Run the Android unit test and verify RED** + +Run: + +```powershell +Set-Location android +.\gradlew.bat :app:testDebugUnitTest --tests "wtf.openstrap.openstrap_edge.HealthConnectSleepWriterTest.cleanupRangeIncludesLegacyFragmentThatStartsBeforeRecomputedSession" +``` + +Expected: compilation fails because `sleepCleanupRange` and `SleepCleanupRange` do not exist. + +- [ ] **Step 3: Add a failing DST-boundary test** + +Add: + +```kotlin +@Test +fun cleanupRangeUsesLocalNoonAcrossDstTransition() { + val sessionStart = localInstant(2026, 10, 25, 1, 30) + val sessionEnd = localInstant(2026, 10, 25, 9, 0) + + val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) + + assertEquals(localInstant(2026, 10, 24, 12, 0), range.start) + assertEquals(localInstant(2026, 10, 25, 12, 0), range.end) + assertEquals(25, Duration.between(range.start, range.end).toHours()) +} +``` + +Run: + +```powershell +Set-Location android +.\gradlew.bat :app:testDebugUnitTest --tests "wtf.openstrap.openstrap_edge.HealthConnectSleepWriterTest.cleanupRangeUsesLocalNoonAcrossDstTransition" +``` + +Expected: compilation fails because the cleanup helper does not exist. + +- [ ] **Step 4: Implement the minimal pure cleanup helper** + +In `HealthConnectSleepWriter.kt`, add imports for `LocalTime` and `ZonedDateTime`, then add: + +```kotlin +internal data class SleepCleanupRange(val start: Instant, val end: Instant) + +internal fun sleepCleanupRange(start: Instant, end: Instant, zoneId: ZoneId): SleepCleanupRange { + require(start.isBefore(end)) + val localEnd = end.atZone(zoneId) + val endDate = if (localEnd.toLocalTime().isBefore(LocalTime.NOON)) { + localEnd.toLocalDate() + } else { + localEnd.toLocalDate().plusDays(1) + } + val cleanupEnd = endDate.atTime(LocalTime.NOON).atZone(zoneId).toInstant() + val calculatedStart = endDate.minusDays(1).atTime(LocalTime.NOON).atZone(zoneId).toInstant() + val cleanupStart = if (start.isBefore(calculatedStart)) start else calculatedStart + return SleepCleanupRange(cleanupStart, cleanupEnd) +} +``` + +- [ ] **Step 5: Use the cleanup range for native deletion only** + +Immediately before `client.deleteRecords`, calculate: + +```kotlin +val cleanupRange = sleepCleanupRange( + session.startTime, + session.endTime, + ZoneId.systemDefault(), +) +``` + +Replace the current exact-session filter with: + +```kotlin +TimeRangeFilter.between(cleanupRange.start, cleanupRange.end) +``` + +Do not change the inserted `session` or its stages. + +- [ ] **Step 6: Run native and existing sleep tests and verify GREEN** + +Run: + +```powershell +Set-Location android +.\gradlew.bat :app:testDebugUnitTest --tests "wtf.openstrap.openstrap_edge.HealthConnectSleepWriterTest" +Set-Location .. +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\flutter.bat' test test/health_sleep_export_test.dart +``` + +Expected: both commands exit 0 and all tests pass. + +- [ ] **Step 7: Format and commit the focused fix** + +Run: + +```powershell +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\dart.bat' format lib test +git diff --check +git add android/app/build.gradle.kts android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt +git commit -m "fix(health): remove legacy sleep fragments" +``` + +Expected: formatting and `git diff --check` succeed; the commit contains only the three listed files. + +### Task 2: Full verification and device proof + +**Files:** +- No production changes expected. +- Verify: `build/app/outputs/flutter-apk/app-release.apk` + +**Interfaces:** +- Consumes: the completed Task 1 commit. +- Produces: analyzer/test/build/device evidence and an installable APK. + +- [ ] **Step 1: Run static analysis and focused health tests** + +```powershell +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\flutter.bat' analyze +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\flutter.bat' test test/health_sleep_export_test.dart test/health_heart_rate_export_test.dart +``` + +Expected: analyzer reports no issues and focused tests pass. + +- [ ] **Step 2: Run the full Flutter suite** + +```powershell +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\flutter.bat' test +``` + +Expected: no new health failures. Record any pre-existing Windows DST or timing-sensitive failures exactly rather than hiding them. + +- [ ] **Step 3: Build and copy the release APK** + +```powershell +& 'C:\Users\felix\.cache\flutter-sdk-3.41.6\flutter\bin\flutter.bat' build apk --release +Copy-Item -Force 'build\app\outputs\flutter-apk\app-release.apk' 'C:\Users\felix\Desktop\OpenStrap-Edge-fix-193.apk' +Get-FileHash 'C:\Users\felix\Desktop\OpenStrap-Edge-fix-193.apk' -Algorithm SHA256 +``` + +Expected: build exits 0, the desktop APK exists, and a SHA-256 hash is recorded. + +- [ ] **Step 4: Install without deleting app data and trigger export** + +```powershell +adb install -r 'C:\Users\felix\Desktop\OpenStrap-Edge-fix-193.apk' +adb shell am start -n wtf.openstrap.openstrap_edge/.MainActivity +``` + +Expected: installation reports `Success`; the existing package, database, and WHOOP pairing remain in place. + +- [ ] **Step 5: Verify Health Connect and Google Health** + +Use the existing OpenStrap profile `Sync now` action. Confirm: + +- OpenStrap reports at least one synchronized day. +- Logcat has no `OpenStrapSleepExport` error or `API call quota exceeded` message. +- Health Connect has one OpenStrap parent for `01:36–08:20` with all stages. +- The stale `23:34–02:55` OpenStrap fragments are absent. +- Google Health no longer reports the stale `2 h 19 min` night after its Health Connect sync completes. + +- [ ] **Step 6: Push and update PR #196** + +Fast-forward `fix/health-connect-sleep-session` to the verified worktree branch, push it to `origin`, and update PR #196 with the legacy-fragment cleanup evidence. Preserve `Fixes #193` in the PR description. From 56bebf3bb8c2f06df63dd23be551d3fc1c659c63 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 14:40:07 +0200 Subject: [PATCH 16/19] fix(health): remove legacy sleep fragments --- android/app/build.gradle.kts | 1 + .../HealthConnectSleepWriter.kt | 25 ++++++++++- .../HealthConnectSleepWriterTest.kt | 42 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5db0937e..6930dd05 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -137,4 +137,5 @@ dependencies { // runtime transitively, but as an `implementation` dep it isn't visible to app // code at compile time — declare it explicitly for our native Worker. implementation("androidx.work:work-runtime-ktx:2.9.1") + testImplementation("junit:junit:4.13.2") } diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt index 6234eb96..42c80fbb 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt @@ -10,7 +10,9 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.time.Instant +import java.time.LocalTime import java.time.ZoneId +import java.time.ZonedDateTime import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -20,6 +22,22 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +internal data class SleepCleanupRange(val start: Instant, val end: Instant) + +internal fun sleepCleanupRange(start: Instant, end: Instant, zoneId: ZoneId): SleepCleanupRange { + require(start.isBefore(end)) + val localEnd = end.atZone(zoneId) + val endDate = if (localEnd.toLocalTime().isBefore(LocalTime.NOON)) { + localEnd.toLocalDate() + } else { + localEnd.toLocalDate().plusDays(1) + } + val cleanupEnd = endDate.atTime(LocalTime.NOON).atZone(zoneId).toInstant() + val calculatedStart = endDate.minusDays(1).atTime(LocalTime.NOON).atZone(zoneId).toInstant() + val cleanupStart = if (start.isBefore(calculatedStart)) start else calculatedStart + return SleepCleanupRange(cleanupStart, cleanupEnd) +} + /** Writes one Health Connect sleep parent containing all normalized stages. */ object HealthConnectSleepWriter { private const val TAG = "OpenStrapSleepExport" @@ -55,13 +73,18 @@ object HealthConnectSleepWriter { false } else { val client = HealthConnectClient.getOrCreate(context) + val cleanupRange = sleepCleanupRange( + session.startTime, + session.endTime, + ZoneId.systemDefault(), + ) // This removes both records created by this writer and legacy // one-stage fragments whose intervals fall inside the real // overnight window, including the portion before midnight. client.deleteRecords( SleepSessionRecord::class, - TimeRangeFilter.between(session.startTime, session.endTime), + TimeRangeFilter.between(cleanupRange.start, cleanupRange.end), ) client.insertRecords(listOf(session)).recordIdsList.size == 1 } diff --git a/android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt b/android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt new file mode 100644 index 00000000..722af767 --- /dev/null +++ b/android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt @@ -0,0 +1,42 @@ +package wtf.openstrap.openstrap_edge + +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthConnectSleepWriterTest { + private val berlin = ZoneId.of("Europe/Berlin") + + private fun localInstant(year: Int, month: Int, day: Int, hour: Int, minute: Int): Instant = + ZonedDateTime.of(year, month, day, hour, minute, 0, 0, berlin).toInstant() + + @Test + fun cleanupRangeIncludesLegacyFragmentThatStartsBeforeRecomputedSession() { + val sessionStart = localInstant(2026, 8, 6, 1, 36) + val sessionEnd = localInstant(2026, 8, 6, 8, 20) + val staleFragmentStart = localInstant(2026, 8, 5, 23, 34) + + val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) + + assertEquals(localInstant(2026, 8, 5, 12, 0), range.start) + assertEquals(localInstant(2026, 8, 6, 12, 0), range.end) + assertTrue(!staleFragmentStart.isBefore(range.start)) + assertTrue(staleFragmentStart.isBefore(range.end)) + } + + @Test + fun cleanupRangeUsesLocalNoonAcrossDstTransition() { + val sessionStart = localInstant(2026, 10, 25, 1, 30) + val sessionEnd = localInstant(2026, 10, 25, 9, 0) + + val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) + + assertEquals(localInstant(2026, 10, 24, 12, 0), range.start) + assertEquals(localInstant(2026, 10, 25, 12, 0), range.end) + assertEquals(25, Duration.between(range.start, range.end).toHours()) + } +} From 78fd9d9b6ef53654f44b90df0fd94c7c81dfd7f4 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 19:39:35 +0200 Subject: [PATCH 17/19] fix(health): continue bulk export after priority sleep --- lib/health/health_export.dart | 20 ++++++++++++++- test/health_sleep_export_test.dart | 41 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index cb72e987..59430e0f 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -84,6 +84,23 @@ bool shouldAttemptHealthExport({ return lastAttempt == null || now.difference(lastAttempt) >= backoff; } +bool shouldAttemptHealthBulkExport({ + required int attempts, + required int maxAttempts, + required DateTime now, + required DateTime? lastAttempt, + required Duration backoff, + required bool prioritySleepAlreadyWritten, + bool force = false, +}) => shouldAttemptHealthExport( + attempts: attempts, + maxAttempts: maxAttempts, + now: now, + lastAttempt: lastAttempt, + backoff: backoff, + force: force || prioritySleepAlreadyWritten, +); + class PrioritySleepExportResult { const PrioritySleepExportResult({ required this.date, @@ -464,7 +481,7 @@ class HealthExporter { var ok = false; var giveUp = false; - final shouldAttempt = shouldAttemptHealthExport( + final shouldAttempt = shouldAttemptHealthBulkExport( attempts: attempts, maxAttempts: _kMaxExportAttempts, now: DateTime.fromMillisecondsSinceEpoch(nowMs), @@ -472,6 +489,7 @@ class HealthExporter { ? null : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), backoff: _backoffFor(attempts), + prioritySleepAlreadyWritten: date == androidSleepAlreadyWritten, force: forceRetry, ); if (!shouldAttempt && attempts >= _kMaxExportAttempts) { diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 14eb6e02..80f30dee 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -53,6 +53,47 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Health Connect sleep-session export regression', () { + test( + 'successful priority sleep bypasses retry backoff and cap for same-day bulk', + () { + final now = DateTime(2026, 8, 6, 12); + + expect( + shouldAttemptHealthBulkExport( + attempts: 2, + maxAttempts: 6, + now: now, + lastAttempt: now.subtract(const Duration(seconds: 1)), + backoff: const Duration(hours: 2), + prioritySleepAlreadyWritten: true, + ), + isTrue, + ); + expect( + shouldAttemptHealthBulkExport( + attempts: 6, + maxAttempts: 6, + now: now, + lastAttempt: now.subtract(const Duration(seconds: 1)), + backoff: const Duration(hours: 2), + prioritySleepAlreadyWritten: false, + ), + isFalse, + ); + expect( + shouldAttemptHealthBulkExport( + attempts: 6, + maxAttempts: 6, + now: now, + lastAttempt: now.subtract(const Duration(days: 1)), + backoff: const Duration(hours: 2), + prioritySleepAlreadyWritten: true, + ), + isTrue, + ); + }, + ); + test( 'newest detected sleep is written before bulk and only once', () async { From e83640d2f3c75dd8644a45db409784de7323d617 Mon Sep 17 00:00:00 2001 From: FlixiDoe Date: Thu, 6 Aug 2026 20:07:58 +0200 Subject: [PATCH 18/19] chore(health): log false export results --- lib/health/health_export.dart | 70 +++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 59430e0f..6530b4de 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -587,7 +587,10 @@ class HealthExporter { // The native replace owns SleepSessionRecord cleanup on Android. if (Platform.isAndroid && !androidSleepAlreadyWritten) { try { - if (!await _androidSleep.replace(b)) success = false; + if (!await _androidSleep.replace(b)) { + debugPrint('[health] write Android sleep session returned false'); + success = false; + } } catch (e) { debugPrint('[health] write Android sleep session: $e'); success = false; @@ -603,7 +606,10 @@ class HealthExporter { startTime: dayStart, endTime: dayEnd, ); - if (!deleted) success = false; + if (!deleted) { + debugPrint('[health] delete ${t.name} returned false'); + success = false; + } } catch (e) { debugPrint('[health] delete ${t.name}: $e'); success = false; @@ -636,7 +642,10 @@ class HealthExporter { endTime: t, unit: unit, ); - if (!wrote) success = false; + if (!wrote) { + debugPrint('[health] write ${type.name} returned false'); + success = false; + } } catch (e) { debugPrint('[health] write ${type.name}: $e'); success = false; @@ -708,7 +717,10 @@ class HealthExporter { endTime: bucketBounds[i + 1], unit: HealthDataUnit.KILOCALORIE, ); - if (!wrote) success = false; + if (!wrote) { + debugPrint('[health] write active energy bucket $i returned false'); + success = false; + } } catch (e) { debugPrint('[health] write energy bucket $i: $e'); success = false; @@ -731,7 +743,10 @@ class HealthExporter { endTime: bucketBounds[i + 1], unit: HealthDataUnit.KILOCALORIE, ); - if (!wrote) success = false; + if (!wrote) { + debugPrint('[health] write basal energy bucket $i returned false'); + success = false; + } } catch (e) { debugPrint('[health] write basal energy bucket $i: $e'); success = false; @@ -757,22 +772,25 @@ class HealthExporter { debugPrint('[health] query continuous hr: $e'); success = false; } - if (hrRows != null && - !await exportContinuousHeartRateDay( - rows: hrRows, - start: dayStart, - end: dayEnd, - useAndroidBatch: Platform.isAndroid, - androidWriter: _androidHeartRate, - writeGeneric: (sample, sampleEnd) => _health.writeHealthData( - value: sample.beatsPerMinute.toDouble(), - type: HealthDataType.HEART_RATE, - startTime: sample.time, - endTime: sampleEnd, - unit: HealthDataUnit.BEATS_PER_MINUTE, - ), - )) { - success = false; + if (hrRows != null) { + final wroteHeartRate = await exportContinuousHeartRateDay( + rows: hrRows, + start: dayStart, + end: dayEnd, + useAndroidBatch: Platform.isAndroid, + androidWriter: _androidHeartRate, + writeGeneric: (sample, sampleEnd) => _health.writeHealthData( + value: sample.beatsPerMinute.toDouble(), + type: HealthDataType.HEART_RATE, + startTime: sample.time, + endTime: sampleEnd, + unit: HealthDataUnit.BEATS_PER_MINUTE, + ), + ); + if (!wroteHeartRate) { + debugPrint('[health] write continuous heart rate returned false'); + success = false; + } } // Steps (24/7 estimate) over the whole day. @@ -786,7 +804,10 @@ class HealthExporter { endTime: dayEnd, unit: HealthDataUnit.COUNT, ); - if (!wrote) success = false; + if (!wrote) { + debugPrint('[health] write steps returned false'); + success = false; + } } catch (e) { debugPrint('[health] write steps: $e'); success = false; @@ -838,7 +859,10 @@ class HealthExporter { } if (rows != null) { for (final r in rows) { - if (await _writeOneWorkout(r) == false) success = false; + if (await _writeOneWorkout(r) == false) { + debugPrint('[health] write workout returned false'); + success = false; + } } } } From 273ed4fc130e6b737d7a7a4ba8d0e977fdd8c983 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 7 Aug 2026 00:49:09 +0530 Subject: [PATCH 19/19] health: a missing hypnogram must not fail the whole day's export `HealthConnectSleepSessionExporter.replace` returned FALSE when a day had a valid sleep window but no stages -- and the caller treats false as a hard failure of the ENTIRE day. `health_export.dart` sets `success = false`, which stops the export cursor advancing, so steps, calories, heart rate and every other unrelated metric for that day are withheld and retried on backoff because one hypnogram was missing. The asymmetry is the tell, three lines apart: if (session == null) return true; // no window at all -> fine if (session.stages.isEmpty) return false; // window, no stages -> FAIL Both are "nothing to write here". Only one said so. This is not a corner case. Days without staging are ordinary: * an IMPORTED day (NOOP / WHOOP CSV) carries a sleep window but no per-second substrate to stage from -- so imported days could never complete a Health Connect export at all, for anything; * a night where staging failed keeps its window too. Deliberately conservative: this does NOT invent a stage-less SleepSessionRecord, it only stops a missing hypnogram failing everything else. Writing the bare session span, so imported days still contribute sleep DURATION, is a genuine improvement -- but it depends on how Health Connect handles a stage-less record, so it belongs in its own change verified on a device rather than guessed at here. One existing test pinned the old return value; updated, keeping its real assertion (an empty hypnogram must never delete native sleep data) intact and recording WHY the value flipped. Two tests added for the case that actually broke: an imported-shaped day with no `series` at all, and the no-window / no-stages symmetry that was the bug. NOT fixed here, flagged instead: `delete()` returning false is also treated as a hard failure, and a no-op delete (nothing of ours to remove -- a first-ever export) is indistinguishable from a real failure at that API, since the plugin returns a bare bool for both. Changing it would risk masking genuine write failures, so I would rather not guess at it without knowing the plugin's semantics on a real device. Left as-is and raised on the PR. 3 tests added/updated, mutation-verified (restoring `return false` fails exactly those three). Suite 1223 passing; the 6 failures in notification_dedupe_test are pre-existing and reproduce on origin/main unmodified. --- lib/health/health_sleep_session.dart | 21 +++++++- test/health_sleep_export_test.dart | 74 +++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/lib/health/health_sleep_session.dart b/lib/health/health_sleep_session.dart index 4e6f274e..a28ca55f 100644 --- a/lib/health/health_sleep_session.dart +++ b/lib/health/health_sleep_session.dart @@ -160,8 +160,27 @@ class HealthConnectSleepSessionExporter { Future replace(Map bundle) async { final session = normalizeHealthSleepSession(bundle); + // No sleep window at all — nothing to write, and that is not a failure. if (session == null) return true; - if (session.stages.isEmpty) return false; + // A window WITH NO STAGES is the same kind of "nothing to write", and has + // to report the same way. It used to return false, and the caller treats + // false as a hard failure of the ENTIRE day: `success = false` in + // health_export.dart stops the cursor advancing, so steps, calories, heart + // rate — every unrelated metric for that day — is withheld and retried on + // backoff because one hypnogram was missing. + // + // Not a corner case either. Days without staging are ordinary: an IMPORTED + // day (NOOP / WHOOP CSV) carries a sleep window but no per-second substrate + // to stage from, and a night where staging failed keeps its window too. + // Under the old behaviour those days could never complete an export at all. + // + // Deliberately conservative: this does NOT invent a stage-less + // SleepSessionRecord, it only stops a missing hypnogram from failing + // everything else. Writing the bare session span — so imported days still + // contribute sleep DURATION — is a real improvement, but it depends on how + // Health Connect handles a stage-less record and belongs in its own change, + // verified on a device. + if (session.stages.isEmpty) return true; return writer.replace(session); } } diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 80f30dee..045f0125 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -451,7 +451,8 @@ void main() { }); test( - 'an empty normalized hypnogram is retryable and never replaces native data', + 'an empty normalized hypnogram is a benign no-op — it never replaces ' + 'native data, and it must not fail the whole day\'s export', () async { const channel = MethodChannel('openstrap/test_health_connect_empty'); var calls = 0; @@ -472,11 +473,80 @@ void main() { final bundle = _overnightBundle(); ((bundle['series'] as Map)['hypnogram'] as List).clear(); - expect(await exporter.replace(bundle), isFalse); + expect( + await exporter.replace(bundle), + isTrue, + reason: + 'false is a HARD failure for the entire day in health_export.dart ' + '(success = false stops the cursor advancing), so a missing ' + 'hypnogram used to withhold steps/calories/HR too. Imported days ' + 'carry a sleep window with no substrate to stage from, so they ' + 'could never export at all.', + ); expect(calls, 0, reason: 'empty stages must not delete native sleep'); }, ); + test( + 'an IMPORTED-shaped day (sleep window, no series at all) is a no-op, ' + 'not a failure — this is the case that never exported', + () async { + const channel = MethodChannel('openstrap/test_health_connect_imported'); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter( + channel: channel, + ), + ); + // A CSV import gives a window but no per-second substrate to stage + // from, so `series` is absent entirely rather than merely empty. + final bundle = _overnightBundle(); + bundle.remove('series'); + + expect(await exporter.replace(bundle), isTrue); + expect(calls, 0); + }, + ); + + test( + 'a day with NO sleep window and a day with a window but no stages agree ' + '— both are "nothing to write", so both report the same way', + () async { + const channel = MethodChannel('openstrap/test_health_connect_symmetry'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => true); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter( + channel: channel, + ), + ); + + final noWindow = _overnightBundle()..remove('sleep'); + final noStages = _overnightBundle(); + ((noStages['series'] as Map)['hypnogram'] as List).clear(); + + expect(await exporter.replace(noWindow), isTrue); + expect( + await exporter.replace(noStages), + isTrue, + reason: 'the asymmetry between these two WAS the bug', + ); + }, + ); + test( 're-export uses the replace operation and a false result propagates', () async {