Skip to content

Fix Health Connect sleep session fragmentation - #196

Merged
abdulsaheel merged 19 commits into
OpenStrap:mainfrom
FlixiDoe:fix/health-connect-sleep-session
Aug 7, 2026
Merged

Fix Health Connect sleep session fragmentation#196
abdulsaheel merged 19 commits into
OpenStrap:mainfrom
FlixiDoe:fix/health-connect-sleep-session

Conversation

@FlixiDoe

@FlixiDoe FlixiDoe commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • export each detected Android sleep window as one SleepSessionRecord containing all normalized stages
  • order, clip, de-overlap, and reject zero-duration stages before writing
  • replace existing sleep records for the complete overnight window so repeated exports remain idempotent
  • propagate false results from Health Connect writes and allow a manual sync to retry capped/backed-off exports
  • preserve the existing Apple Health stage export path

Root cause

OpenStrap called health 11.1.1's generic writeHealthData() once per hypnogram segment. On Android, that package maps every SLEEP_* call to a separate SleepSessionRecord containing one stage. Health Connect therefore received fragmented parent sessions instead of one parent record containing the complete hypnogram.

The installed dependency does not expose an aggregate sleep-session API, so this adds a small project-local Android MethodChannel dedicated to replacing one typed sleep session with all of its stages.

Validation

  • flutter test test/health_sleep_export_test.dart --reporter expanded — 7/7 passed
  • flutter analyze — no issues found
  • flutter build apk --release — succeeded
  • installed and tested on a Pixel 9 Pro XL; Health Connect showed one 23:55–07:46 Edge session with all awake, REM, light, and deep stages
  • verified with a fresh Google Health account, which imported the corrected session successfully

The full Windows test suite still has three unrelated pre-existing/environmental failures: two DST tests that rely on POSIX timezone mutation and the timing-sensitive DeriveScheduler workout reliability test. The focused regression suite is green.

Fixes #193

Summary by CodeRabbit

  • New Features

    • Added Android Health Connect sleep-session export with sleep stages.
    • Added batched Android heart-rate export.
    • Sleep data is normalized into a single session with validated, non-overlapping stages.
    • Manual health synchronization now supports forced retries.
  • Bug Fixes

    • Improved handling of failed health-data deletions and writes.
    • Invalid or incomplete sleep sessions are safely skipped.
    • Export failures now correctly remain eligible for retry.
    • Prevented overlapping health synchronization operations.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 61e58707-f2a1-49cb-bf30-8140dc6d7cf4

📥 Commits

Reviewing files that changed from the base of the PR and between e83640d and 273ed4f.

📒 Files selected for processing (2)
  • lib/health/health_sleep_session.dart
  • test/health_sleep_export_test.dart
📝 Walkthrough

Walkthrough

This change adds typed Android Health Connect sleep-session and heart-rate exports. It normalizes payloads, replaces existing records, coordinates retries and concurrent synchronization, propagates failed writes, and adds Dart and Android regression coverage.

Changes

Health Connect export

Layer / File(s) Summary
Sleep-session normalization and replacement
lib/health/health_sleep_session.dart, android/app/src/main/kotlin/.../HealthConnectSleepWriter.kt, android/app/build.gradle.kts, android/app/src/test/kotlin/.../HealthConnectSleepWriterTest.kt
Adds typed sleep models, stage normalization, method-channel replacement, DST-aware cleanup, Android validation, and insertion of one parent session.
Batched heart-rate replacement
lib/health/health_heart_rate_batch.dart, android/app/src/main/kotlin/.../HealthConnectHeartRateWriter.kt, android/app/src/main/kotlin/.../NativeChannels.kt, test/health_heart_rate_export_test.dart
Adds sample normalization, Android daily batch replacement, Apple per-sample fallback, native validation, channel registration, and tests.
Export scheduling and failure propagation
lib/health/health_export.dart, lib/state/app_state.dart, test/health_sleep_export_test.dart
Prioritizes Android sleep export, adds forced retries and single-flight synchronization, skips duplicate Android stage writes, and treats false deletion or write results as failures.
Implementation specifications and plans
docs/superpowers/plans/*, docs/superpowers/specs/*
Documents the sleep cleanup and heart-rate batch contracts, integration steps, validation scope, and release verification.
Local workspace exclusions
.gitignore
Ignores local .worktrees/ and .superpowers/ directories.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

  • OpenStrap/edge#83: Both changes modify health export retry and failure handling.
  • OpenStrap/edge#150: Both changes update health export coordination and write-result handling.
  • OpenStrap/edge#158: Both changes modify Health Connect sleep-session export and cleanup behavior.

Suggested labels: Review effort 5/5

Sequence Diagram(s)

sequenceDiagram
  participant AppState
  participant HealthExporter
  participant SleepSessionExporter
  participant HeartRateExporter
  participant HealthConnect
  AppState->>HealthExporter: Start guarded export
  HealthExporter->>SleepSessionExporter: Export newest Android sleep session
  SleepSessionExporter->>HealthConnect: Replace consolidated sleep record
  HealthConnect-->>SleepSessionExporter: Return result
  HealthExporter->>HeartRateExporter: Export normalized heart-rate day
  HeartRateExporter->>HealthConnect: Replace batched heart-rate record
  HealthConnect-->>HeartRateExporter: Return result
  HealthExporter-->>AppState: Return aggregate success and retry state
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds Android heart-rate batching and related tests and documentation, which are unrelated to issue #193. Split heart-rate batching, related documentation, and maintenance changes into a separate pull request.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: fixing fragmented Health Connect sleep sessions.
Linked Issues check ✅ Passed The implementation addresses issue #193 by writing one normalized session, validating stages, replacing records, checking failures, and supporting idempotent exports.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@FlixiDoe

FlixiDoe commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@FlixiDoe
FlixiDoe marked this pull request as ready for review August 5, 2026 12:01
Copilot AI lite review requested due to automatic review settings August 5, 2026 12:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new sleep-session replace path can currently write an empty-stage sleep session and the native Health Connect work is launched on the main dispatcher, both of which can cause incorrect exports and/or UI jank.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Fixes Android Health Connect sleep export fragmentation by replacing per-stage generic writeHealthData() writes with a single native SleepSessionRecord containing all stages, while keeping Apple Health behavior intact and making retries/manual sync more robust.

Changes:

  • Added a project-local Android MethodChannel writer to delete+replace one sleep session record with its full stage list.
  • Normalized sleep stages (order, clip, de-overlap, drop zero-duration) and used idempotent replace semantics for re-exports.
  • Propagated false results from delete/write APIs and enabled manual sync to bypass retry backoff/caps.
File summaries
File Description
test/health_sleep_export_test.dart Adds regression coverage for normalization, single-call stage export, idempotent replace, retry forcing, and concurrency queuing.
lib/state/app_state.dart Forces retry when user triggers manual health sync.
lib/health/health_sleep_session.dart Adds sleep-session normalization + method-channel writer/exporter wrappers.
lib/health/health_export.dart Switches Android sleep export to native replace, improves idempotency/retry behavior by honoring false results.
android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt Registers the new native sleep writer channel.
android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt Implements the native Health Connect delete+insert of a single sleep session with stages.
android/app/build.gradle.kts Adds Health Connect client dependency needed by the native writer.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +161 to +165
Future<bool> replace(Map<String, dynamic> bundle) async {
final session = normalizeHealthSleepSession(bundle);
if (session == null) return true;
return writer.replace(session);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 044d507. A detected sleep window whose stages normalize to an empty list now returns false without invoking the native writer. This keeps the day retryable and cannot delete an existing valid session.

Comment on lines +78 to +86
val rawStages = call.argument<List<Map<String, Any?>>>("stages").orEmpty()
val stages = rawStages.mapNotNull(::buildStage).sortedBy { it.startTime }
var previousEnd = start
for (stage in stages) {
if (stage.startTime.isBefore(start) || stage.endTime.isAfter(end)) return null
if (!stage.startTime.isBefore(stage.endTime)) return null
if (stage.startTime.isBefore(previousEnd)) return null
previousEnd = stage.endTime
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 044d507. The native parser now rejects an empty or fully invalid stage list before any Health Connect delete or insert is attempted.

Comment on lines +26 to +29
private const val RECORDING_METHOD_AUTOMATIC = 2
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val replaceMutex = Mutex()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 044d507. Health Connect parsing/delete/insert work now runs inside withContext(Dispatchers.IO), while the MethodChannel result is delivered after returning to the main scope.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt`:
- Around line 74-75: Update the start and end timestamp parsing in the
sleep-writing method to use the same defensive Number conversion as buildStage:
read each argument without an unchecked Long cast, safely cast it to Number,
convert it with toLong(), and return null when absent or invalid. Keep
Instant.ofEpochMilli as the final conversion.
- Around line 66-69: Update the broad catch in the sleep-session replacement
flow to rethrow kotlinx.coroutines.CancellationException before handling other
Exception values, preserving coroutine cancellation while still returning false
and logging unexpected failures. Add the explicit detekt suppression documenting
why the broad catch is required.
- Line 26: The SleepSessionRecord metadata setup must match connect-client
1.1.0-alpha07. Replace the local RECORDING_METHOD_AUTOMATIC constant and direct
Metadata construction with Metadata.autoRecorded(...) using
Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED when available, and
verify/update the SleepSessionRecord constructor named parameters against the
pinned API, especially title and stages.

In `@lib/health/health_export.dart`:
- Line 621: Update the write predicate in the sleep-stage export branch to use
the same isApple predicate passed to healthDeleteTypes, replacing the broader
!Platform.isAndroid check. Preserve the existing sleep-stage write behavior
while ensuring deletion and writing agree on non-Apple platforms.

In `@lib/health/health_sleep_session.dart`:
- Around line 106-121: Consolidate hypnogram label decoding in _stageOf, and
remove the duplicate label switch from _sleepType in health_export.dart. Update
the health_export.dart flow to call _stageOf and then map the resulting
HealthSleepStage to the appropriate HealthDataType, preserving the existing
Apple and Android export behavior while keeping one shared vocabulary source.

In `@lib/state/app_state.dart`:
- Around line 389-392: Guard healthSyncNow with a single-flight state field,
matching the existing _kickSyncBurst and _orphanRecovery pattern in the
surrounding class. Return the in-flight Future or otherwise prevent a second
forced export while the first _healthExport.exportAll(forceRetry: true) is
running, and clear the guard when it completes so later syncs can proceed.

In `@test/health_sleep_export_test.dart`:
- Around line 228-246: Update the test around
HealthConnectSleepSessionExporter.replace to track the method-channel handler
invocation count independently of storedParents, then assert the expected call
count after both replacements. Keep the storedParents assertions focused on the
final payload, and do not use its length to verify replacement behavior.
- Around line 286-288: In the concurrency test around the native replace calls,
replace the single Future<void>.delayed(Duration.zero) yield with await
pumpEventQueue() so all scheduled tasks are drained before asserting calls
remains 1. Add the flutter_test import at the top of the file if it is not
already present.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 478dfd04-e981-4255-ac60-8c9851d48bf3

📥 Commits

Reviewing files that changed from the base of the PR and between d911f60 and 65dec51.

📒 Files selected for processing (7)
  • android/app/build.gradle.kts
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
  • lib/health/health_export.dart
  • lib/health/health_sleep_session.dart
  • lib/state/app_state.dart
  • test/health_sleep_export_test.dart

Comment thread lib/health/health_export.dart Outdated
Comment thread lib/health/health_sleep_session.dart Outdated
Comment thread lib/state/app_state.dart Outdated
Comment thread test/health_sleep_export_test.dart
Comment thread test/health_sleep_export_test.dart Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/state/app_state.dart`:
- Around line 391-393: Route the automatic export in `_afterDrain` through
`_healthExportSingleFlight.run(...)`, matching the existing `healthSyncNow()`
guard so every `_healthExport.exportAll()` call is serialized. Add a regression
test that overlaps an automatic export with a forced manual export and verifies
both complete without conflicting record or retry-cursor updates.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c598dd7f-4227-47e3-988a-e1f691038504

📥 Commits

Reviewing files that changed from the base of the PR and between 65dec51 and 044d507.

📒 Files selected for processing (5)
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt
  • lib/health/health_export.dart
  • lib/health/health_sleep_session.dart
  • lib/state/app_state.dart
  • test/health_sleep_export_test.dart

Comment thread lib/state/app_state.dart Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt`:
- Around line 27-39: Update sleepCleanupRange so its returned cleanupEnd cannot
extend beyond the exported day interval used by _exportDay. Clamp the calculated
end to the day’s end boundary, or skip/handle cross-boundary sleep windows
separately, while preserving valid cleanup for windows entirely within the day.

In
`@android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt`:
- Around line 17-41: Add a test covering the early-start branch in
sleepCleanupRange, using a session start before local noon on the previous day
so the function returns the session start rather than calculatedStart. Assert
the cleanup range preserves that early start and still uses the expected session
end boundary.

In `@lib/health/health_export.dart`:
- Around line 416-419: Update the !shouldAttempt branch in the export flow so
the under-cap backoff case also calls exportBulk(null) instead of returning 0.
Preserve the existing attempts >= _kMaxExportAttempts fallback and ensure
priority-day backoff still proceeds with bulk export while skipping the sleep
retry.
- Around line 65-71: Align the heart-rate delete filter with the native
batch-writer selection used by exportContinuousHeartRateDay: use the same
Android/platform predicate at the heart-rate delete path instead of
isApplePlatform. Ensure heart-rate deletion occurs whenever the generic
per-sample write fallback is not used, preventing repeated exports from
duplicating samples.

In `@lib/health/health_heart_rate_batch.dart`:
- Around line 105-111: Update the sample export loop around writeGeneric to
clamp each sample’s computed end time to the export end boundary, while
preserving the existing one-minute interval for earlier samples. Add a
regression test covering a sample at end minus 30 seconds and verify the written
interval does not extend beyond end.

In `@lib/state/app_state.dart`:
- Around line 391-396: Update _runHealthExport and its single-flight
coordination so a forceRetry: true caller joining an in-flight non-forced export
chains a subsequent forced _healthExport.exportAll run and returns that forced
result; preserve single-flight behavior for matching requests. Add a regression
test covering an in-flight non-forced export followed by healthSyncNow(),
asserting exportAll is invoked again with forceRetry enabled.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8c9233ea-6feb-40a5-bfcd-5d4732cddbc1

📥 Commits

Reviewing files that changed from the base of the PR and between 044d507 and e83640d.

📒 Files selected for processing (15)
  • .gitignore
  • android/app/build.gradle.kts
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectHeartRateWriter.kt
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt
  • android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt
  • android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt
  • docs/superpowers/plans/2026-08-06-health-connect-legacy-sleep-cleanup.md
  • docs/superpowers/plans/2026-08-06-health-connect-priority-and-heart-rate-batch.md
  • docs/superpowers/specs/2026-08-06-health-connect-heart-rate-batch-design.md
  • docs/superpowers/specs/2026-08-06-health-connect-legacy-sleep-cleanup-design.md
  • lib/health/health_export.dart
  • lib/health/health_heart_rate_batch.dart
  • lib/state/app_state.dart
  • test/health_heart_rate_export_test.dart
  • test/health_sleep_export_test.dart

Comment on lines +27 to +39
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the sleep-window derivation to see whether an offset can land at/after local noon.
set -euo pipefail

fd -e dart . lib --exec rg -n -C4 'offset_ms|onset_ms' {} \; | head -100

printf '\n--- sleep window segmentation source ---\n'
fd -i 'sleep' -e dart . lib

Repository: OpenStrap/edge

Length of output: 4333


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- candidate files ---\n'
fd -e dart . lib | sort
fd -e kt android . | sort

printf '\n--- HealthConnectSleepWriter outline ---\n'
ast-grep outline android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt --view expanded || true

printf '\n--- HealthConnectSleepWriter relevant lines ---\n'
cat -n android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt | sed -n '1,140p'

printf '\n--- references to sleepCleanupRange and replace ---\n'
rg -n "sleepCleanupRange|fun replace|class.*Sleep|replace\\(" android/lib -S

Repository: OpenStrap/edge

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
fd -e dart . lib | sort
fd -e kt android . | sort

printf '%s\n' ''
printf '%s\n' '--- HealthConnectSleepWriter outline ---'
ast-grep outline android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt --view expanded || true

printf '%s\n' ''
printf '%s\n' '--- HealthConnectSleepWriter relevant lines ---'
cat -n android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt | sed -n '1,140p'

printf '%s\n' ''
printf '%s\n' '--- references to sleepCleanupRange and replace ---'
rg -n "sleepCleanupRange|fun replace|class.*Sleep|replace\\(" android/lib -S || true

Repository: OpenStrap/edge

Length of output: 13949


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sleep-related Dart files ---'
fd -e dart . lib | rg -i 'sleep|heal' || true

printf '%s\n' ''
printf '%s\n' '--- sleep session normalizer relevant lines ---'
cat -n lib/health/health_sleep_session.dart | sed -n '1,240p'

printf '%s\n' ''
printf '%s\n' '--- derivation files sleep terms ---'
rg -n -C3 "sleep(\\.window|Window|window)|sleep_|onset|offset|wake_ts|startTime|endTime|offset_ms|onset_ms" lib lib/test --glob '*.dart' || true

printf '%s\n' ''
printf '%s\n' '--- day_id groupby and bulk export references ---'
rg -n -C4 "day_id|groupBy|exportAll|replaceSleepSession|sleepCleanupRange|sleep.window" lib --glob '*.dart' || true

Repository: OpenStrap/edge

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact search for replaceSleepSession call sites ---'
rg -n "replaceSleepSession|HealthConnectSleepWriter|sleepCleanupRange" lib android --glob '*.dart' --glob '*.kt' || true

printf '%s\n' ''
printf '%s\n' '--- sleep export/write channel usage ---'
rg -n "openstrap/health_connect_sleep|HealthConnect|sleep_session|startTime|endTime" lib --glob '*.dart' --max-count 120 || true

printf '%s\n' ''
printf '%s\n' '--- targetDayWindow and day attribution region ---'
cat -n lib/compute/derivation_engine.dart | sed -n '4300,4345p'
cat -n lib/compute/substrate.dart | sed -n '428,434p'

printf '%s\n' ''
python3 - <<'PY'
from datetime import datetime, timezone, timedelta

def cleanup_range_ms(epoch_start_ms, epoch_end_ms, tz_offset_ms):
    zone = timezone(timedelta(milliseconds=tz_offset_ms))
    start = datetime.fromtimestamp(epoch_start_ms/1000, tz=zone)
    end = datetime.fromtimestamp(epoch_end_ms/1000, tz=zone)
    local_end = end.astimezone(zone)
    if local_end.time() < datetime(2000,1,1,12,0).time():
        end_date = local_end.date()
    else:
        end_date = local_end.date() + timedelta(days=1)
    cleanup_end = datetime.combine(end_date, datetime.min.time()).replace(hour=12, tzinfo=zone).timestamp()*1000
    calculated_start_ms = (datetime.combine(end_date - timedelta(days=1), datetime.min.time()).replace(hour=12, tzinfo=zone).timestamp()*1000)
    cleanup_start_ms = min(epoch_start_ms, calculated_start_ms)
    return cleanup_start_ms, cleanup_end_ms

def to_window(epoch):
    end = datetime.fromtimestamp(epoch/1000, tz=timezone.utc) + timedelta(hours=5)
    return end.date().isoformat(), end.time().isoformat()

base = datetime(2026, 8, 3, 0, 0, tzinfo=timezone.utc).timestamp()*1000
cases = [
    ("nap ending 13:00 day N and following night ending 07:00 day N+1",
     base + 13*3600*1000, base + 24*3600*1000 + 7*3600*1000, +5*3600*1000),
]
for label, start, end, offset in cases:
    cstart, cend = cleanup_range_ms(start, end, offset)
    print(label)
    print("session:", to_window(start), to_window(end))
    print("cleanup:", to_window(cstart), to_window(cend))
    print("overlap:", (max(start, cstart) < min(end, cend)) and (max(start, cstart) < min(cend, end)))
PY

printf '%s\n' ''
printf '%s\n' '--- tests around sleepCleanupRange if present ---'
fd -e kt . | rg -i test || true
rg -n "sleepCleanupRange|SleepCleanupRange|noon|LocalTime.NOON|cleanup" . --glob '*.kt' --glob '*.test.*' --glob '*_test.*' || true

Repository: OpenStrap/edge

Length of output: 10473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- HealthConnect sleep writer call path ---'
cat -n lib/health/health_sleep_session.dart | sed -n '120,210p'
cat -n lib/health/health_export.dart | sed -n '560,620p'
cat -n lib/health/health_export.dart | sed -n '780,850p'

printf '%s\n' ''
printf '%s\n' '--- day bundle grouping and replacement context ---'
cat -n lib/health/health_export.dart | sed -n '540,560p'
rg -n -C2 "day_id|sleep_session_candidates|fromMap|toMap|sleep:" lib/health/health_sleep_session.dart lib/health/health_export.dart

printf '%s\n' ''
printf '%s\n' '--- derive window and daylight search horizon ---'
cat -n lib/compute/derivation_engine.dart | sed -n '540,580p'
cat -n lib/compute/substrate.dart | sed -n '464,488p'
cat -n lib/compute/derivation_engine.dart | sed -n '1932,1950p'

Repository: OpenStrap/edge

Length of output: 15423


Keep sleep cleanup inside the day window.

_exportDay marks success if the sleep replace succeeds, then deletes other data only for [dayStart, dayEnd). If a sleep window exits the local day, replace() can then delete records beyond dayEnd while the day is considered exported, and later data for that exported day is not re-published in that pass. Clamp cleanupRange.end to the exported day interval, or avoid exporting a sleep window that crosses the day boundary without handling the cleanup boundary separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriter.kt`
around lines 27 - 39, Update sleepCleanupRange so its returned cleanupEnd cannot
extend beyond the exported day interval used by _exportDay. Clamp the calculated
end to the day’s end boundary, or skip/handle cross-boundary sleep windows
separately, while preserving valid cleanup for windows entirely within the day.

Comment on lines +17 to +41
@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())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the early-start branch of sleepCleanupRange.

Both tests exercise only the path where calculatedStart wins on line 37. The branch that returns the session start (a session starting before local noon of the previous day) has no coverage. That branch protects a long session from a truncated cleanup window.

♻️ Proposed test
+    `@Test`
+    fun cleanupRangeExtendsBackToAnEarlySessionStart() {
+        val sessionStart = localInstant(2026, 8, 5, 9, 15)
+        val sessionEnd = localInstant(2026, 8, 6, 8, 20)
+
+        val range = sleepCleanupRange(sessionStart, sessionEnd, berlin)
+
+        assertEquals(sessionStart, range.start)
+        assertEquals(localInstant(2026, 8, 6, 12, 0), range.end)
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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())
}
`@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())
}
`@Test`
fun cleanupRangeExtendsBackToAnEarlySessionStart() {
val sessionStart = localInstant(2026, 8, 5, 9, 15)
val sessionEnd = localInstant(2026, 8, 6, 8, 20)
val range = sleepCleanupRange(sessionStart, sessionEnd, berlin)
assertEquals(sessionStart, range.start)
assertEquals(localInstant(2026, 8, 6, 12, 0), range.end)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android/app/src/test/kotlin/wtf/openstrap/openstrap_edge/HealthConnectSleepWriterTest.kt`
around lines 17 - 41, Add a test covering the early-start branch in
sleepCleanupRange, using a session start before local noon on the previous day
so the function returns the session start rather than calculatedStart. Assert
the cleanup range preserves that early start and still uses the expected session
end boundary.

Comment on lines +65 to +71
: types
.where(
(type) =>
!_sleepHealthTypes.contains(type) &&
type != HealthDataType.HEART_RATE,
)
.toList();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Align the heart-rate delete predicate with the heart-rate write predicate.

Line 69 removes HealthDataType.HEART_RATE from the delete list when isApplePlatform is false. Line 780 selects the native batch writer with Platform.isAndroid. The two predicates disagree on any platform that is neither Apple nor Android: no heart-rate delete runs, but exportContinuousHeartRateDay falls back to the generic per-sample writes. A repeated export then duplicates heart-rate samples instead of replacing them.

This is the same predicate mismatch that was already fixed for the sleep-stage writes on line 821. Use one predicate for both heart-rate paths.

🐛 Proposed fix
-        useAndroidBatch: Platform.isAndroid,
+        useAndroidBatch: !isApple,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/health/health_export.dart` around lines 65 - 71, Align the heart-rate
delete filter with the native batch-writer selection used by
exportContinuousHeartRateDay: use the same Android/platform predicate at the
heart-rate delete path instead of isApplePlatform. Ensure heart-rate deletion
occurs whenever the generic per-sample write fallback is not used, preventing
repeated exports from duplicating samples.

Comment on lines +416 to 419
if (!shouldAttempt) {
if (attempts >= _kMaxExportAttempts) return exportBulk(null);
return 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Run the bulk export when the priority sleep day is only backing off.

Line 418 returns 0 when the priority day is under the attempt cap and not yet due for retry. No bulk export runs in that pass. One failed sleep write therefore stops the export of steps, HRV, energy, workouts, and every older day for the whole backoff tier, which reaches 24 hours.

The give-up branch on line 417 already falls through to exportBulk(null). The under-cap backoff branch is the outlier. During backoff no sleep write is attempted, so no Health Connect quota is reserved for sleep and withholding the bulk export gains nothing.

This also contradicts the retry-cursor design note on lines 306-321, which states that the pipeline must not be wedged indefinitely on one bad day.

🐛 Proposed fix
             if (!shouldAttempt) {
-              if (attempts >= _kMaxExportAttempts) return exportBulk(null);
-              return 0;
+              // Not due for retry (or capped): the sleep write is not
+              // attempted this pass, so it holds no Health Connect quota —
+              // let every other day/metric proceed rather than stalling the
+              // whole pipeline behind one failing night.
+              return exportBulk(null);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!shouldAttempt) {
if (attempts >= _kMaxExportAttempts) return exportBulk(null);
return 0;
}
if (!shouldAttempt) {
// Not due for retry (or capped): the sleep write is not
// attempted this pass, so it holds no Health Connect quota —
// let every other day/metric proceed rather than stalling the
// whole pipeline behind one failing night.
return exportBulk(null);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/health/health_export.dart` around lines 416 - 419, Update the
!shouldAttempt branch in the export flow so the under-cap backoff case also
calls exportBulk(null) instead of returning 0. Preserve the existing attempts >=
_kMaxExportAttempts fallback and ensure priority-day backoff still proceeds with
bulk export while skipping the sleep retry.

Comment on lines +105 to +111
var success = true;
for (final sample in samples) {
try {
if (!await writeGeneric(
sample,
sample.time.add(const Duration(minutes: 1)),
)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clamp the Apple sample interval at the export boundary.

A sample in the final minute passes the [start, end) filter. Line 110 then creates an interval after end. This can write data into the next export window.

Clamp the generic write end to end. Add a regression test with a sample at end - 30 seconds.

Proposed fix
   var success = true;
   for (final sample in samples) {
+    final sampleEnd = sample.time.add(const Duration(minutes: 1));
     try {
       if (!await writeGeneric(
         sample,
-        sample.time.add(const Duration(minutes: 1)),
+        sampleEnd.isAfter(end) ? end : sampleEnd,
       )) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var success = true;
for (final sample in samples) {
try {
if (!await writeGeneric(
sample,
sample.time.add(const Duration(minutes: 1)),
)) {
var success = true;
for (final sample in samples) {
final sampleEnd = sample.time.add(const Duration(minutes: 1));
try {
if (!await writeGeneric(
sample,
sampleEnd.isAfter(end) ? end : sampleEnd,
)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/health/health_heart_rate_batch.dart` around lines 105 - 111, Update the
sample export loop around writeGeneric to clamp each sample’s computed end time
to the export end boundary, while preserving the existing one-minute interval
for earlier samples. Add a regression test covering a sample at end minus 30
seconds and verify the written interval does not extend beyond end.

Comment thread lib/state/app_state.dart
Comment on lines +391 to +396
Future<int> healthSyncNow() => _runHealthExport(forceRetry: true);

Future<int> _runHealthExport({bool forceRetry = false}) =>
_healthExportSingleFlight.run(
() => _healthExport.exportAll(forceRetry: forceRetry),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

A manual sync can silently lose forceRetry when it joins an in-flight automatic export.

_healthExportSingleFlight.run returns the existing in-flight future when one exists. healthSyncNow() therefore joins an automatic _runHealthExport() pass that was started with forceRetry: false. The forced pass never runs. Days that are still inside their backoff window stay skipped, and healthSyncNow returns the day count of the non-forced run, so the UI reports a successful manual sync.

_afterDrain calls _runHealthExport() on every light and heavy derive pass, so this overlap is common rather than rare.

Chain a forced pass after the joined run when the caller asked for forceRetry and the in-flight run was not forced. Add a regression test that starts a non-forced export, calls healthSyncNow() while it is in flight, and asserts that a forced exportAll still runs.

🛡️ Proposed direction
-  Future<int> _runHealthExport({bool forceRetry = false}) =>
-      _healthExportSingleFlight.run(
-        () => _healthExport.exportAll(forceRetry: forceRetry),
-      );
+  /// A forced (user-initiated) export must not be satisfied by joining a
+  /// non-forced pass already in flight — that pass honours the per-day
+  /// backoff, which is exactly what "Sync now" exists to bypass.
+  bool _healthExportForcedInFlight = false;
+
+  Future<int> _runHealthExport({bool forceRetry = false}) async {
+    final joinedNonForced = forceRetry && !_healthExportForcedInFlight;
+    final joined = await _healthExportSingleFlight.run(() {
+      _healthExportForcedInFlight = forceRetry;
+      return _healthExport
+          .exportAll(forceRetry: forceRetry)
+          .whenComplete(() => _healthExportForcedInFlight = false);
+    });
+    if (!joinedNonForced) return joined;
+    return _healthExportSingleFlight.run(() {
+      _healthExportForcedInFlight = true;
+      return _healthExport
+          .exportAll(forceRetry: true)
+          .whenComplete(() => _healthExportForcedInFlight = false);
+    });
+  }

As per coding guidelines: "Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/state/app_state.dart` around lines 391 - 396, Update _runHealthExport and
its single-flight coordination so a forceRetry: true caller joining an in-flight
non-forced export chains a subsequent forced _healthExport.exportAll run and
returns that forced result; preserve single-flight behavior for matching
requests. Add a regression test covering an in-flight non-forced export followed
by healthSyncNow(), asserting exportAll is invoked again with forceRetry
enabled.

Source: Coding guidelines

`HealthConnectSleepSessionExporter.replace` returned FALSE when a day had a
valid sleep window but no stages -- and the caller treats false as a hard
failure of the ENTIRE day. `health_export.dart` sets `success = false`, which
stops the export cursor advancing, so steps, calories, heart rate and every
other unrelated metric for that day are withheld and retried on backoff
because one hypnogram was missing.

The asymmetry is the tell, three lines apart:

    if (session == null) return true;          // no window at all -> fine
    if (session.stages.isEmpty) return false;  // window, no stages -> FAIL

Both are "nothing to write here". Only one said so.

This is not a corner case. Days without staging are ordinary:

  * an IMPORTED day (NOOP / WHOOP CSV) carries a sleep window but no
    per-second substrate to stage from -- so imported days could never
    complete a Health Connect export at all, for anything;
  * a night where staging failed keeps its window too.

Deliberately conservative: this does NOT invent a stage-less
SleepSessionRecord, it only stops a missing hypnogram failing everything
else. Writing the bare session span, so imported days still contribute sleep
DURATION, is a genuine improvement -- but it depends on how Health Connect
handles a stage-less record, so it belongs in its own change verified on a
device rather than guessed at here.

One existing test pinned the old return value; updated, keeping its real
assertion (an empty hypnogram must never delete native sleep data) intact and
recording WHY the value flipped. Two tests added for the case that actually
broke: an imported-shaped day with no `series` at all, and the no-window /
no-stages symmetry that was the bug.

NOT fixed here, flagged instead: `delete()` returning false is also treated as
a hard failure, and a no-op delete (nothing of ours to remove -- a first-ever
export) is indistinguishable from a real failure at that API, since the plugin
returns a bare bool for both. Changing it would risk masking genuine write
failures, so I would rather not guess at it without knowing the plugin's
semantics on a real device. Left as-is and raised on the PR.

3 tests added/updated, mutation-verified (restoring `return false` fails
exactly those three). Suite 1223 passing; the 6 failures in
notification_dedupe_test are pre-existing and reproduce on origin/main
unmodified.
@abdulsaheel

Copy link
Copy Markdown
Collaborator

Reviewed and pushed 273ed4f.

A missing hypnogram failed the whole day's export

HealthConnectSleepSessionExporter.replace returned false for a day with a valid sleep window but no stages — and the caller treats false as a hard failure of the entire day. health_export.dart:590 sets success = false, which stops the export cursor advancing, so steps, calories, heart rate and every other unrelated metric for that day get withheld and retried on backoff because one hypnogram was missing.

The asymmetry is the tell — three lines apart:

if (session == null) return true;          // no window at all  -> fine
if (session.stages.isEmpty) return false;  // window, no stages -> FAIL

Both are "nothing to write here". Only one said so.

And it isn't a corner case. Days without staging are ordinary:

  • an imported day (NOOP / WHOOP CSV) carries a sleep window but no per-second substrate to stage from — so imported days could never complete a Health Connect export at all, for anything;
  • a night where staging failed keeps its window too.

Deliberately conservative

This does not invent a stage-less SleepSessionRecord — it only stops a missing hypnogram from failing everything else. Writing the bare session span, so imported days still contribute sleep duration, is a genuine improvement, but it depends on how Health Connect handles a stage-less record. That belongs in its own change verified on a device rather than guessed at here. Happy to do it if you've seen how the platform behaves.

On the test I changed

One existing test pinned the old return value ('an empty normalized hypnogram is retryable and never replaces native data'). I kept its real assertion — an empty hypnogram must never delete native sleep data — and recorded in the test why the value flipped, so this doesn't look like someone bending a test to fit. Two tests added for the case that actually broke: an imported-shaped day with no series at all, and the no-window / no-stages symmetry.

Flagged, NOT fixed — delete() returning false

delete() returning false is also treated as a hard failure (health_export.dart:608 and :938). The concern is that a no-op delete — nothing of ours to remove, e.g. a first-ever export — is indistinguishable from a real failure at that API, since the plugin returns a bare bool for both.

I've left it alone on purpose. Changing it would risk masking genuine write failures, and I can't verify the plugin's actual semantics from here. Worth a device check: if a no-op delete does return false on Health Connect, then every day's first export is marked failed and burns retry attempts. If you know the answer offhand, that's a one-line fix; if not, it's worth measuring before touching.

Verification

3 tests added/updated, mutation-verified — restoring return false fails exactly those three. Suite 1223 passing / 6 failing; the 6 are notification_dedupe_test and reproduce on origin/main unmodified, so they're pre-existing and unrelated.

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Checked the bot findings against my change, because one of them looks like it contradicts it.

Copilot on health_sleep_session.dart — does NOT conflict with my fix

"...it will currently attempt a native replace even if the hypnogram is empty (stages=[]). That can delete a previous valid session and insert an empty session, while still counting the day as exported."

That was written against an earlier commit, before if (session.stages.isEmpty) return false; existed. It's a real concern, and my change does not reintroduce it — the early return happens before writer.replace(session):

if (session == null) return true;
if (session.stages.isEmpty) return true;   // <- returns here
return writer.replace(session);            // <- never reached

So no native delete+insert occurs. I deliberately kept the original test's expect(calls, 0, reason: 'empty stages must not delete native sleep') assertion for exactly this reason, and only changed the return value. Both properties now hold at once: no native write, and no failure of the day's other metrics.

Copilot on HealthConnectSleepWriter.kt:86 — still worth doing

"buildSession() currently allows inserting a SleepSessionRecord with an empty stages list."

Still valid as defence in depth. Dart no longer sends empty stages, but the native side shouldn't rely on that. Left it alone since it's your Kotlin and a one-liner you may want to shape yourself.

Copilot on Dispatchers.Main.immediate — real, and the one I'd prioritise

Health Connect delete/insert under a main-thread dispatcher can do binder/IO work. This repo has a documented history of ANRs from exactly this class of thing (v42 shipped them). Worth moving to Dispatchers.IO.

CodeRabbit's isApple vs !Platform.isAndroid mismatch — real

healthDeleteTypes(isApplePlatform: false) strips every SLEEP_* type from the delete list while the write block still writes per-stage sleep, so on any platform that's neither Apple nor Android the two predicates disagree. Small, and I left it as it's cleanly yours to fix.

CodeRabbit's CancellationException swallow and unchecked call.argument<Long> casts — both real

The as? Number then .toLong() form is already used in buildStage a few lines below, so it's just consistency.


I fixed only the one that silently withheld a whole day's export; the rest are yours to take or leave. Note the two Kotlin ones and the delete()-returns-false question I raised earlier all point the same direction — this PR's Dart side is in good shape and the remaining risk is concentrated in the native writer.

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Correction to my earlier note on this PR, and it's good news.

I described the 6 notification_dedupe_test failures as "pre-existing, reproduce on origin/main unmodified" and suggested they deserved their own issue. The first half was right; the framing was wrong, and I've now root-caused them.

They are a time bomb, not a standing breakage. The suite builds date-prefixed dedupe keys from a hardcoded 2026-07-23, and FiredKeyStore prunes dated flags older than retentionDays (14). While that date was recent the keys stayed inside the window; once it aged past 14 days, every key was pruned the instant it was written, so repeat emits fired again:

Expected: <1>   Actual: <3>

main passed CI on 2026-08-04 when the date was 12 days old, and has been failing since the window closed — same commit, no code change.

Proved it by substituting today's date into the unmodified file on origin/main: all 15 turn green. Fixed in #207 (test-only, no lib/ change); the full suite is 1201 passing, 0 failing with it.

Practical impact here: this PR's CI cannot go green until #207 merges, regardless of its own content. Sorry for the noise — "pre-existing and unrelated" was accurate but undersold that it was actively blocking you.

@abdulsaheel
abdulsaheel merged commit 9c1104f into OpenStrap:main Aug 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Health Connect export creates fragmented sleep sessions instead of one complete session

3 participants