Skip to content

gate the workout health write on a delete that actually cleared - #301

Merged
abdulsaheel merged 1 commit into
OpenStrap:mainfrom
DropTabl:fix/health-workout-delete-gate
Aug 28, 2026
Merged

gate the workout health write on a delete that actually cleared#301
abdulsaheel merged 1 commit into
OpenStrap:mainfrom
DropTabl:fix/health-workout-delete-gate

Conversation

@DropTabl

@DropTabl DropTabl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

The bug

exportWorkout and _exportDay both document delete-then-write idempotency, but
called the write even when the preceding _health.delete(type: WORKOUT, ...)
returned false or threw — they only recorded success = false and carried on.

If an already-exported copy survived the failed delete and the write then
succeeded, the store ended up with two of the same workout — and the false
return drove a retry through the attempts/backoff/give-up machinery that would
write a third.

Pre-existing; found during review of claude/workout-general-bowling-143476
but not caused by it, so it is split out here.

Why the write can't just be gated on delete() == true

The two stores answer the empty range differently, which is the fact the
whole fix hinges on:

store empty range → so false means
Health Connect true a genuine failure, always
HealthKit false almost always "we never wrote here"
  • Health Connect (HealthPlugin.deleteData, health 12.2.1) calls
    deleteRecords over a time range and reports success unless it threw. A
    zero-match range does not throw.
  • HealthKit (SwiftHealthPlugin.delete) queries our own samples via
    HKSource.default() and hands whatever came back — including an empty
    array
    — to HKHealthStore.delete. Apple documents that parameter as:
    "Deleting an empty array fails with an errorInvalidArgument error."

So on Apple false is the normal answer for a window we have never written.
Gating on it would have broken every first export on iOS, permanently.

Two corroborations that this is real:

  • The plugin's own sibling deleteByUUID explicitly guards !samples.isEmpty
    result(false); the range delete() just forgot the guard.
  • health_export.dart already reasoned about exactly this for STEPS: "a false
    delete() flips success … could permanently stall a day's export cursor."

The fix

healthDeleteClearedRange({deleted, ios}) => deleted || ios encodes the
asymmetry: gate on Android, never on Apple.

Trusting Apple's false is not optimism — it is the only reading the platform
supports, and it is safe: the HealthKit failures that can leave one of our
samples behind (share permission never requested, or denied) suppress the
following write for the same authorization reason, so they cannot produce the
duplicate the gate exists to stop. A throw is a genuine failure on both
stores and is the one case that reports "not cleared" everywhere.

Three sites rewired through a _deleteOwnSamples helper:

  • exportWorkout — bails without writing when the window is not cleared; still
    returns false so the retry happens, but the retry re-attempts the delete
    first.
  • _exportDay's rewrite loop — tracks workoutCleared.
  • the per-day workout loop — skipped only when the day's WORKOUT delete
    genuinely failed, so a failed workout export cannot pause that day's unrelated
    RHR/HRV/sleep/energy exports.

Side effect worth calling out

On iOS an empty-range delete was flipping a day's success to false on
essentially every fresh export, driving the retry cursor with no actual
failure behind it. That stops too.

Test

test/health_workout_export_delete_gate_test.dart — a fake store over the
plugin's own flutter_health method channel. Verified to fail against the
pre-fix code
:

Expected: not contains 'writeWorkoutData'
  Actual: ['delete', 'writeWorkoutData']

Five cases: both platform branches of the predicate, plus delete-false /
delete-throws / delete-succeeds through exportWorkout. Parameterised on ios
rather than reading Platform, for the same reason healthActivityForType
already is — so a host VM can exercise both branches.

Verification

  • flutter test+3169 ~423 -1. The single failure is
    gen5_pairing_filter_test.dart (Dart↔Swift present(items, allowGen4Retry:)
    lockstep), confirmed pre-existing by re-running it with
    health_export.dart restored from HEAD — it fails identically.
  • flutter analyze clean on both changed files.

Note for anyone running the suite in a fresh worktree: lib/l10n/app_localizations*.dart
is gitignored generated output and flutter test did not auto-generate it
here despite generate: true. Run flutter gen-l10n first or ~55 tests fail to
compile for unrelated reasons.

Not fixed here

The scalar types (RHR/HRV/respiratory/energy) still write unconditionally after
a failed delete and can still double on Health Connect. That needs per-type
"cleared" state threaded through writeAt/writeGeneric — a different-shaped
change into the retry machinery. Documented in the rewrite loop rather than
silently left.

Summary by Sourcery

Gate workout writes on a cleared deletion window while accounting for the different empty-range responses from Health Connect and HealthKit.

Bug Fixes:

  • Prevent workout exports from writing replacement samples when deletion did not clear the existing window, avoiding duplicate workouts across retries.
  • Preserve first-time HealthKit exports by treating its empty-range delete result as cleared while requiring successful deletion on Health Connect.
  • Keep failed workout deletion from blocking unrelated daily health exports.

Enhancements:

  • Centralize platform-specific delete-cleared handling and reuse it across standalone workout and daily rewrite exports.

Tests:

  • Add coverage for platform-specific delete semantics and workout export behavior after failed, thrown, and successful deletes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved workout export reliability across Apple HealthKit and Android Health Connect.
    • Prevented duplicate or conflicting workout records when existing data cannot be cleared.
    • Workout exports now safely stop instead of writing alongside records that remain in the export window.

exportWorkout and _exportDay both document delete-then-write idempotency but
called the write even when the preceding delete returned false or threw --
they only recorded success = false and carried on. If an already-exported
copy survived the failed delete and the write then succeeded, the store ended
up with two of the same workout, and the false return drove a retry through
the attempts/backoff/give-up machinery that would write a third.

The write cannot simply be gated on `delete() == true`, because the two
stores answer the EMPTY range differently:

  * Health Connect (HealthPlugin.deleteData) calls deleteRecords over a time
    range and reports success unless it threw. A zero-match range is a normal
    success, so false there is always a genuine failure.

  * HealthKit (SwiftHealthPlugin.delete) queries our own samples via
    HKSource.default() and hands whatever came back -- including an EMPTY
    array -- to HKHealthStore.delete, which Apple documents as "Deleting an
    empty array fails with an errorInvalidArgument error". So on Apple false
    is the normal answer for a window we have never written, and gating on it
    would mean no first export ever reaches HealthKit.

healthDeleteClearedRange encodes that asymmetry: gate on Android, never on
Apple. Trusting Apple's false is not optimism, it is the only reading the
platform supports -- and the HealthKit failures that CAN leave one of our
samples behind (share permission never requested, or denied) suppress the
following write for the same authorization reason, so they cannot produce the
duplicate the gate exists to stop. A throw is a genuine failure on both.

That same reading also stops an empty-range delete from flipping a day's
success on Apple, where it was marking essentially every fresh export as
failed and driving the retry cursor with no actual failure behind it.

In the per-day path only the workout block is skipped when the day's WORKOUT
delete fails, so a failed workout export cannot pause that day's unrelated
RHR/HRV/sleep/energy exports. The scalar types still write unconditionally
and can still double on Health Connect -- noted in the rewrite loop, not
fixed here.

healthDeleteClearedRange takes `ios` rather than reading Platform for the
same reason healthActivityForType does, so the new fake-store test can
exercise both branches on a host VM.
@sourcery-ai

sourcery-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes workout delete-then-write idempotency by normalizing HealthKit and Health Connect delete results, suppressing writes when a Health Connect delete genuinely fails or throws, and retrying the delete before writing again; day-level export isolates the workout failure from unrelated health metrics.

Sequence diagram for gated workout export retry

sequenceDiagram
    participant Caller
    participant Exporter
    participant HealthStore
    participant Writer
    Caller->>Exporter: exportWorkout(session)
    Exporter->>HealthStore: delete(type, startTime, endTime)
    alt delete throws or Health Connect returns false
        HealthStore-->>Exporter: failure
        Exporter-->>Caller: false
        Caller->>Exporter: retry exportWorkout(session)
        Exporter->>HealthStore: delete(type, startTime, endTime)
    else delete succeeds or HealthKit returns false
        HealthStore-->>Exporter: cleared
        Exporter->>Writer: _writeOneWorkout(session)
        Writer-->>Exporter: write result
        Exporter-->>Caller: write result
    end
Loading

Flow diagram for platform-aware workout delete gating

flowchart TD
    A[Workout export requested] --> B[_deleteOwnSamples]
    B --> C[_health.delete]
    C --> D{Delete result}
    D -->|throws| E[Return not cleared]
    D -->|true| F[healthDeleteClearedRange]
    D -->|false| F
    F -->|Health Connect and false| E
    F -->|HealthKit and false| G[Window treated as cleared]
    F -->|true on either platform| G
    E --> H[Return false without write]
    H --> I[Retry re-attempts delete]
    G --> J[_writeOneWorkout]
Loading

Flow diagram for isolating day-level workout failure

flowchart TD
    A[_exportDay] --> B[Rewrite WORKOUT and scalar types]
    B --> C{WORKOUT delete cleared?}
    C -->|no| D[Set workoutCleared false]
    D --> E[Skip day workout writes]
    E --> F[Continue unrelated RHR/HRV/sleep/energy exports]
    C -->|yes| G[Process day workout rows]
    G --> H[_writeOneWorkout]
Loading

File-Level Changes

Change Details Files
Introduces platform-aware delete-clearance semantics and centralizes delete handling before workout writes.
  • Treats a false delete as failure on Health Connect but as a valid empty-range result on HealthKit; treats exceptions as failure on both.
  • Adds a helper that deletes app-owned samples, normalizes platform behavior, and logs failures.
  • Adds focused tests for both predicate branches and delete failure, exception, and success paths.
lib/health/health_export.dart
test/health_workout_export_delete_gate_test.dart
Prevents workout writes when the preceding delete did not clear the target window while preserving retry behavior.
  • Gates standalone workout export on successful clearance and returns false without writing when clearance fails.
  • Tracks workout deletion separately in day export so only workout rows are skipped; unrelated scalar exports continue.
  • Retains success=false to drive retries, which retry deletion before attempting the write.
lib/health/health_export.dart
Documents the remaining scope and platform-specific rationale for the idempotency fix.
  • Explains HealthKit empty-array delete behavior and why Apple false results must not block first exports.
  • Calls out that scalar types remain ungated and may still duplicate after failed Health Connect deletes.
lib/health/health_export.dart

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="lib/health/health_export.dart" line_range="126-127" />
<code_context>
+/// Parameterised on [ios] rather than reading `Platform` for the same reason
+/// [healthActivityForType] is — so a host-VM test can exercise both branches.
+@visibleForTesting
+bool healthDeleteClearedRange({required bool deleted, required bool ios}) =>
+    deleted || ios;
+
 /// Cursor for the one-shot Apple Health sleep rewrite. Bump when the writer
</code_context>
<issue_to_address>
**issue (broader_impact):** On Apple, `deleted == false` is treated as a cleared range even when HealthKit returned no readable samples because the app requests only write authorization. An existing app-owned workout can therefore survive the delete while `_writeOneWorkout` writes another copy, recreating the duplicate the gate is intended to prevent.

**Triggers:** When the user has write authorization but lacks HealthKit read authorization, or read access otherwise returns an empty result for an existing sample.

**Suggested fix:** Request and verify the necessary read authorization before relying on the HealthKit query, or make the delete API distinguish an empty range from a failed/unauthorized query instead of treating every Apple `false` as safe.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and this changes the delete-then-write behavior for persisted health records; if the platform-specific interpretation is wrong, a workout could be duplicated or an export could be skipped. Reverting stops the behavior, but any duplicate record already written survives the revert and must be removed or recomputed.

Blocking findings: lib/health/health_export.dart:127


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +126 to +127
bool healthDeleteClearedRange({required bool deleted, required bool ios}) =>
deleted || ios;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (broader_impact): On Apple, deleted == false is treated as a cleared range even when HealthKit returned no readable samples because the app requests only write authorization. An existing app-owned workout can therefore survive the delete while _writeOneWorkout writes another copy, recreating the duplicate the gate is intended to prevent.

Triggers: When the user has write authorization but lacks HealthKit read authorization, or read access otherwise returns an empty result for an existing sample.

Suggested fix: Request and verify the necessary read authorization before relying on the HealthKit query, or make the delete API distinguish an empty range from a failed/unauthorized query instead of treating every Apple false as safe.

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a305b4d5-bd9d-46fd-8da9-c4c86280f52d

📥 Commits

Reviewing files that changed from the base of the PR and between 855ddd7 and 001ffff.

⛔ Files ignored due to path filters (1)
  • test/health_workout_export_delete_gate_test.dart is excluded by !test/**
📒 Files selected for processing (1)
  • lib/health/health_export.dart

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The health export flow now interprets deletion results by platform. Daily and standalone workout exports skip writes when the workout window is not cleared.

Changes

Health export deletion flow

Layer / File(s) Summary
Platform-aware deletion contract
lib/health/health_export.dart
Adds healthDeleteClearedRange and _deleteOwnSamples. Apple treats a false delete result as cleared, while Android treats it as uncleared. Delete exceptions return false.
Workout rewrite guards
lib/health/health_export.dart
Daily exports track workoutCleared and skip workout writes when deletion fails. exportWorkout returns false without writing when the workout window is not cleared.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 001ff

The change prevents workout writes after failed deletes on Android and after exceptions, but on Apple it treats every non-throwing false delete result as a cleared range. If that result can represent a real deletion failure, a replacement write could leave duplicate workouts, so explicit platform-owner confirmation is advisable before merge.

Suggested reviewers: abdulsaheel, flixidoe

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: workout health writes now depend on a delete that successfully cleared the range.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@abdulsaheel
abdulsaheel merged commit 1bf0a0d into OpenStrap:main Aug 28, 2026
4 checks 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.

2 participants