Skip to content

Fix/workmanager scope and lifecycle - #83

Merged
abdulsaheel merged 4 commits into
mainfrom
fix/workmanager-scope-and-lifecycle
Jul 16, 2026
Merged

Fix/workmanager scope and lifecycle#83
abdulsaheel merged 4 commits into
mainfrom
fix/workmanager-scope-and-lifecycle

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

This pull request introduces several improvements and fixes across the codebase, focusing on background task management, health data export accuracy, and UI robustness. The most significant changes include safer and more precise handling of background tasks to avoid interfering with unrelated system jobs, enhancements to health data export granularity, and minor UI code safety improvements.

Background Task Management:

  • Refactored background derivation task names to use public, uniquely-scoped constants (kHeavyDeriveTaskName, kSyncTaskName) to ensure that only the intended WorkManager jobs are cancelled during app startup, preventing accidental cancellation of unrelated jobs like native watchdogs. (lib/compute/background_derivation.dart, lib/main.dart) [1] [2] [3] [4] [5] [6]

Health Data Export Improvements:

  • Export of active and basal energy is now chunked into hourly buckets instead of a single daily value, and active energy subtracts workout calories to prevent double-counting. (lib/health/health_export.dart)
  • Added export of continuous heart rate data as minute-by-minute averages. (lib/health/health_export.dart)
  • Included HealthDataType.HEART_RATE in the list of exported health data types. (lib/health/health_export.dart)

UI and Code Safety:

  • Improved the Pressable widget to check if the widget is still mounted before calling setState, preventing possible errors. (lib/ui/design/pressable.dart)
  • In StepCalibrationScreen, cached the AppState instance in initState to avoid repeated context lookups and ensure proper cleanup. (lib/ui/today/step_calibration_screen.dart) [1] [2]

Other:

  • Refactored initialization of the _pulse animation controller in _LiveBannerState to occur in initState, aligning with best practices for resource management. (lib/app.dart)

Summary by CodeRabbit

  • New Features
    • Health data exports now include continuous heart-rate samples.
    • Active and basal energy are exported as hourly samples for improved detail.
  • Bug Fixes
    • Prevented updates to UI state after pressable widgets are disposed.
    • Improved step-calibration cleanup when leaving the screen.
    • Improved reliability of health export retries and background derivation/sync scheduling.
  • Chores
    • Refined background job identifiers and added platform-conditional startup cleanup.
    • Improved animation controller initialization for the live banner.

Workmanager().cancelAll() maps straight to the native
WorkManager.cancelAllWork() (verified in the workmanager_android plugin
source) — unscoped, OS-wide, not limited to jobs the Dart plugin itself
registered. EdgeApplication.onCreate() (native Kotlin) schedules the
KeepAliveWorker FGS-restart watchdog on that same shared WorkManager
instance BEFORE this Dart main() runs, so the unscoped cancelAll() was
wiping the watchdog out on every single cold start — silently
defeating the exact background-reliability mechanism it exists for.

Replaced with cancelByUniqueName() targeting only the two legacy task
names (openstrap.derive.heavy / openstrap.sync) this cleanup actually
means to remove. Made those names public in background_derivation.dart
(kHeavyDeriveTaskName / kSyncTaskName) instead of duplicating the
literals in main.dart.

flutter analyze: no new issues.
- app.dart: construct _LiveBanner's AnimationController in initState
  instead of a late-final field initializer.
- pressable.dart: guard the pointer-event setState calls with
  `mounted` — pointer callbacks can fire after the widget disposes.
- step_calibration_screen.dart: cache the AppState ref in initState
  and use it in dispose() instead of calling context.read there,
  which can fail once the widget tree is tearing down.
- health_export.dart: export continuous per-minute heart rate from
  decoded_onehz; spread active/basal energy into hourly buckets;
  subtract workout calories from the daily active-energy total to
  avoid double-counting against the totalEnergyBurned already
  attached to each exported HKWorkout.
@coderabbitai

coderabbitai Bot commented Jul 15, 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 064eab1d-fb57-46dc-9854-63c39582bff8

📥 Commits

Reviewing files that changed from the base of the PR and between 71cc63d and a489f01.

📒 Files selected for processing (1)
  • lib/health/health_export.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/health/health_export.dart

📝 Walkthrough

Walkthrough

The changes add persisted health-export retries, hourly energy and minute-level heart-rate samples, public WorkManager task names with Android cleanup, and lifecycle-safe widget state handling.

Changes

Health data export

Layer / File(s) Summary
Export retry and success tracking
lib/health/health_export.dart
Persists per-day retry state, applies bounded backoff, uses DST-safe day boundaries, and propagates export failures.
Hourly energy and heart-rate export
lib/health/health_export.dart
Requests heart rate, exports energy hourly, and writes positive minute-averaged heart-rate samples.

WorkManager task cleanup

Layer / File(s) Summary
Task-name contract and scheduling
lib/compute/background_derivation.dart
Defines public task-name constants and uses them for dispatcher routing and periodic registrations.
Android startup cleanup
lib/main.dart
Cancels the heavy-derive and sync jobs on Android startup while ignoring cancellation errors.

Widget lifecycle handling

Layer / File(s) Summary
Animation, pointer, and calibration lifecycle safety
lib/app.dart, lib/ui/design/pressable.dart, lib/ui/today/step_calibration_screen.dart
Initializes the pulse controller in initState, guards pointer updates with mounted, and caches AppState for disposal-time cancellation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HealthExport
  participant LocalDb
  participant HealthPlatform
  HealthExport->>LocalDb: Load retry state and export cursor
  LocalDb-->>HealthExport: Return calories, workouts, and minute readings
  HealthExport->>HealthExport: Compute hourly energy and minute heart-rate samples
  HealthExport->>HealthPlatform: Delete and write health samples
  HealthPlatform-->>HealthExport: Return operation results
  HealthExport->>LocalDb: Persist retry state and export cursor
Loading
🚥 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 is concise and clearly reflects the PR’s main focus on WorkManager/task lifecycle changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/workmanager-scope-and-lifecycle

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 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/health/health_export.dart`:
- Around line 274-284: Update _exportDay and the related export blocks around
the workout query and health writes so failures are not swallowed: catch errors
per sample, track an aggregate success flag, mark workout-query failures as
unsuccessful, and return false whenever required prerequisites or expected
writes fail. Preserve processing of remaining samples while ensuring finalized
days cannot advance after partial exports.
- Around line 275-277: Update the sessionsInRange call in the health export flow
to make the upper bound exclusive by passing dayEnd minus one second, or use an
equivalent exclusive-range query. Keep dayStart inclusive so sessions starting
at the next midnight are excluded from the current day.
- Around line 288-295: Update the daily health export logic around the hourly
ACTIVE_ENERGY_BURNED loop to calculate the next local calendar midnight rather
than using dayStart plus 24 elapsed hours. Use that boundary for the hourly
iteration and associated heart-rate query, ensuring buckets remain within the
intended local date across DST transitions.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e8e9cc22-62fa-4c19-a6d7-e0b9398a266d

📥 Commits

Reviewing files that changed from the base of the PR and between 08ac410 and dbe615c.

📒 Files selected for processing (6)
  • lib/app.dart
  • lib/compute/background_derivation.dart
  • lib/health/health_export.dart
  • lib/main.dart
  • lib/ui/design/pressable.dart
  • lib/ui/today/step_calibration_screen.dart

Comment thread lib/health/health_export.dart Outdated
Comment thread lib/health/health_export.dart Outdated
Comment thread lib/health/health_export.dart Outdated
- Aggregate success per day instead of unconditionally returning true:
  every write/query now catches independently (was one try/catch per
  loop, so a single failed write aborted the rest of that loop's
  samples too) and _exportDay returns false if anything failed.
- Exclusive upper bound (dayEnd - 1s) on both sessionsInRange calls
  (calorie subtraction AND workout export) so a workout starting
  exactly at midnight no longer gets double-subtracted/double-written
  across both adjacent days.
- DST-safe day boundary: dayEnd is now the real next local midnight
  (calendar-field DateTime construction) instead of dayStart + 24h of
  absolute Duration, and the hourly active/basal energy buckets are
  built from that real span instead of fixed 1-hour offsets. Verified
  against 2026-03-08 (23h spring-forward) and 2026-11-01 (25h
  fall-back) under TZ=America/New_York — every bucket, including the
  last, now stays inside the correct calendar day.

exportAll()'s cursor now actually uses _exportDay's success signal
(previously it only checked `finalized`, never the day's own result).
Went with bounded retry-with-backoff (5m/30m/2h/6h/24h, persisted as a
small JSON blob in the existing sync_cursor table) rather than
indefinite blocking or a bare CodeRabbit-literal fix, matching existing
precedent for this exact class of problem: derivation_engine.dart's
skip markers "so a pathological day isn't retried forever" and the BLE
layer's BondRefusalGiveUp. After 6 failed attempts a day is given up on
(cursor advances past it, logged) so one persistently-failing day can't
wedge every later day's export forever. Permission-not-granted writes
silently no-op rather than throw, so they never enter this retry path.

flutter analyze: clean (same 5 pre-existing unrelated issues).
flutter test --concurrency=1: 457/457 passing.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 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/health/health_export.dart`:
- Around line 245-287: Update the retry handling around _exportDay to persist
each entry’s finalized state and detect a transition from non-finalized to
finalized, resetting attempts and retry metadata before retrying the finalized
payload. Ensure a newly finalized day is not skipped because of the prior cap,
and set giveUp immediately when a finalized export attempt reaches
_kMaxExportAttempts while preserving cursor advancement behavior.
- Around line 369-375: Update the bucket construction around bucketSpan and
bucketBounds to advance by one elapsed hour until dayEnd, rather than dividing
the day into 24 equal spans. Derive bucketCount from the generated boundaries
and use it for energy division and all related loops in the active/basal energy
writers. Ensure the final bucket remains clipped to dayEnd.
- Around line 196-204: Update _backoffFor so attempts == 1 selects the first
_kRetryBackoff entry, while preserving clamping for zero and values beyond the
available retry schedule.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 692d79f2-4dc0-402c-8287-3fe9b951eca9

📥 Commits

Reviewing files that changed from the base of the PR and between dbe615c and 71cc63d.

📒 Files selected for processing (1)
  • lib/health/health_export.dart

Comment thread lib/health/health_export.dart Outdated
Comment on lines +245 to +287
final entry = (retryState[date] as Map?)?.cast<String, dynamic>();
final attempts = (entry?['attempts'] as num?)?.toInt() ?? 0;
final lastAttemptMs = (entry?['last_ms'] as num?)?.toInt();
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;
}
} else {
final nextAttempts = attempts + 1;
retryState[date] = {'attempts': nextAttempts, 'last_ms': nowMs};
retryStateDirty = true;
debugPrint(
'[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);
}
// Advance the cursor only while the finalized prefix stays unbroken; the
// first non-finalized day stops it (that day re-exports next pass).
if (prefixContiguous && finalized) {
// 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)) {

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reset capped retries when a day becomes finalized.

A non-finalized day that reaches six failures remains permanently capped. When it later becomes finalized, _exportDay() is skipped and the cursor advances without ever attempting the finalized payload. Persist the entry’s finalized state and reset attempts on that transition; also set giveUp immediately when a finalized attempt reaches the cap.

🤖 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 245 - 287, Update the retry
handling around _exportDay to persist each entry’s finalized state and detect a
transition from non-finalized to finalized, resetting attempts and retry
metadata before retrying the finalized payload. Ensure a newly finalized day is
not skipped because of the prior cap, and set giveUp immediately when a
finalized export attempt reaches _kMaxExportAttempts while preserving cursor
advancement behavior.

Comment thread lib/health/health_export.dart Outdated
- _backoffFor off-by-one: entries record attempts starting at 1 (right
  after the first failure), but the lookup indexed by attempts
  directly, so the first retry got the 30min tier instead of the
  intended 5min one. Index by attempts-1.
- Retry cap didn't reset on the non-finalized -> finalized transition:
  a day that racked up 6 failures while still in the mutable "recent
  tail" state would hit giveUp=true forever after finalizing, so
  _exportDay was never even attempted against the final payload. Now
  persists `finalized` per retry entry and resets attempts/backoff on
  that transition, so a newly-finalized day always gets a fresh
  attempt budget instead of inheriting a cap earned against the old
  version.
- Hourly energy buckets were dividing the day into 24 equal spans
  (57.5min/62.5min "hours" on the two DST-transition days) instead of
  real elapsed clock-hours. Switched to building boundaries by adding
  one real hour at a time up to dayEnd, clipping the final bucket;
  bucketCount now varies (23/24/25) instead of the bucket width.
  Re-verified under TZ=America/New_York on both transition dates:
  every bucket is exactly 1:00:00 except the correctly-clipped final
  one, and the last boundary still lands exactly on dayEnd.

flutter analyze: clean (same 5 pre-existing unrelated issues).
flutter test --concurrency=1: 457/457 passing.
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.

1 participant