diff --git a/.gitignore b/.gitignore index 0548b9f..f1331ae 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/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ae33004..6930dd0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -130,8 +130,12 @@ 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. 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/HealthConnectHeartRateWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt new file mode 100644 index 0000000..12bf6e2 --- /dev/null +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt @@ -0,0 +1,117 @@ +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 { + val request = buildRequest(call) ?: return@withLock false + if (request.samples.isEmpty()) return@withLock true + if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { + false + } else { + 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/HealthConnectSleepWriter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt new file mode 100644 index 0000000..42c80fb --- /dev/null +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt @@ -0,0 +1,151 @@ +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 +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.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +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 + +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" + private const val CHANNEL = "openstrap/health_connect_sleep" + private const val REPLACE_SLEEP_SESSION = "replaceSleepSession" + 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 { + 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 session = buildSession(call) + if (session == null) { + 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(cleanupRange.start, cleanupRange.end), + ) + 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 + } + } + } + + private fun buildSession(call: MethodCall): SleepSessionRecord? { + 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 { (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 + 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 = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, + ), + ) + } + + 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 ecbe0e4..2b98277 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,9 @@ object NativeChannels { fun register(engine: FlutterEngine, context: Context) { val app = context.applicationContext + HealthConnectSleepWriter.register(engine, app) + HealthConnectHeartRateWriter.register(engine, app) + MethodChannel(engine.dartExecutor.binaryMessenger, EDGE_TRACKING_CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { 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 0000000..722af76 --- /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()) + } +} 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 0000000..d86c537 --- /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. 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 0000000..3abaf29 --- /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. + 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 0000000..4278e85 --- /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. 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 0000000..de724cd --- /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. diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 55eaf15..6530b4d 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -25,6 +25,8 @@ 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. enum HealthLinkState { @@ -36,10 +38,135 @@ 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) && + type != HealthDataType.HEART_RATE, + ) + .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; +} + +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, + 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); +} + +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; + + 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( + 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; @@ -56,20 +183,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 @@ -125,8 +252,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'); } @@ -188,9 +317,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 = [ @@ -215,7 +343,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 { @@ -227,94 +359,202 @@ 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 - } - final finalized = (row['finalized'] as num?)?.toInt() == 1; - final bundle = _decode(row['payload_json']); - if (bundle == null || bundle['skipped'] == true) { - if (!finalized) prefixContiguous = false; - continue; - } + if (cursor.isNotEmpty && date.compareTo(cursor) <= 0) continue; + pendingDays.add(( + date: date, + finalized: (row['finalized'] as num?)?.toInt() == 1, + bundle: _decode(row['payload_json']), + )); + } - 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; - if (attempts >= _kMaxExportAttempts) { - giveUp = true; - } else if (lastAttemptMs != null && - nowMs - lastAttemptMs < _backoffFor(attempts).inMilliseconds) { - // 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) - if (ok) { - if (entry != null) { - retryState.remove(date); - retryStateDirty = true; + 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)); + } + + var bulkDone = 0; + try { + final priorityResult = await exportPrioritySleepBeforeBulk( + newestFirstDays: priorityDays, + write: _androidSleep.replace, + exportBulk: (androidSleepAlreadyWritten) async { + 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 exportBulk(null); + } + + 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 = shouldAttemptHealthBulkExport( + attempts: attempts, + maxAttempts: _kMaxExportAttempts, + now: DateTime.fromMillisecondsSinceEpoch(nowMs), + lastAttempt: lastAttemptMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), + backoff: _backoffFor(attempts), + prioritySleepAlreadyWritten: date == androidSleepAlreadyWritten, + 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; @@ -323,7 +563,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 @@ -338,11 +582,34 @@ 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 && !androidSleepAlreadyWritten) { + try { + 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; + } + } + // 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 { - await _health.delete(type: t, startTime: dayStart, endTime: dayEnd); + final deleted = await _health.delete( + type: t, + startTime: dayStart, + endTime: dayEnd, + ); + if (!deleted) { + debugPrint('[health] delete ${t.name} returned false'); + success = false; + } } catch (e) { debugPrint('[health] delete ${t.name}: $e'); success = false; @@ -360,16 +627,25 @@ 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 { - 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) { + debugPrint('[health] write ${type.name} returned false'); + success = false; + } } catch (e) { debugPrint('[health] write ${type.name}: $e'); success = false; @@ -377,11 +653,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 @@ -405,8 +689,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; @@ -425,12 +710,17 @@ 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) { + debugPrint('[health] write active energy bucket $i returned false'); + success = false; + } } catch (e) { debugPrint('[health] write energy bucket $i: $e'); success = false; @@ -446,12 +736,17 @@ 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) { + debugPrint('[health] write basal energy bucket $i returned false'); + success = false; + } } catch (e) { debugPrint('[health] write basal energy bucket $i: $e'); success = false; @@ -467,33 +762,34 @@ 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', - [startTs, endTs]); + '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'); 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 { - await _health.writeHealthData( - value: avgHr, - type: HealthDataType.HEART_RATE, - startTime: t, - endTime: t.add(const Duration(minutes: 1)), - unit: HealthDataUnit.BEATS_PER_MINUTE); - } catch (e) { - debugPrint('[health] write continuous hr @$minuteTs: $e'); - success = false; - } - } + 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; } } @@ -501,37 +797,48 @@ 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) { + debugPrint('[health] write steps returned false'); + 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; - try { - await _health.writeHealthData( + // 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 (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 = healthSleepStageOf(s['stage']?.toString()); + if (st == null || en == null || en <= st || stage == null) continue; + final type = _sleepType(stage); + 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; + } } } @@ -543,15 +850,19 @@ 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; } 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; + } } } } @@ -569,18 +880,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,33 +929,36 @@ 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; } } - 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; } } @@ -697,7 +1014,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/health/health_heart_rate_batch.dart b/lib/health/health_heart_rate_batch.dart new file mode 100644 index 0000000..840c55e --- /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/lib/health/health_sleep_session.dart b/lib/health/health_sleep_session.dart new file mode 100644 index 0000000..a28ca55 --- /dev/null +++ b/lib/health/health_sleep_session.dart @@ -0,0 +1,186 @@ +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 = healthSleepStageOf(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? healthSleepStageOf(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); + // No sleep window at all — nothing to write, and that is not a failure. + if (session == null) return true; + // 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/lib/state/app_state.dart b/lib/state/app_state.dart index dfc502b..6397108 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,12 @@ class AppState extends ChangeNotifier { } /// Export all finalized-but-unexported days now. Returns days written. - Future healthSyncNow() async { - final n = await _healthExport.exportAll(); - return n; - } + 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 /// #130) — used by callers outside this class (e.g. confirming an @@ -921,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_heart_rate_export_test.dart b/test/health_heart_rate_export_test.dart new file mode 100644 index 0000000..6340e2f --- /dev/null +++ b/test/health_heart_rate_export_test.dart @@ -0,0 +1,211 @@ +import 'dart:io'; + +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); + }); + + 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)')), + ); + }); + }); +} + +class _UnusedHeartRateWriter implements HealthConnectHeartRateWriter { + var calls = 0; + + @override + Future replaceDay( + DateTime start, + DateTime end, + List samples, + ) async { + calls++; + return true; + } +} diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart new file mode 100644 index 0000000..045f012 --- /dev/null +++ b/test/health_sleep_export_test.dart @@ -0,0 +1,629 @@ +import 'dart:async'; +import 'dart:io'; + +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; + +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( + '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 { + 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 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('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 { + var priorityWrites = 0; + var totalSleepWrites = 0; + final bulkPriorityDates = []; + + 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']); + }, + ); + + test('Android generic cleanup never deletes sleep records', () { + 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( + 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('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); + 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); + + 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( + '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('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()); + + 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( + '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; + 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), + 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 { + 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(writes, 2, reason: 'each export sends exactly one replace call'); + 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 pumpEventQueue(); + + 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); + }, + ); + }); +}