Skip to content

Stillness / "Time to move" nudge fires on app-open instead of in the background (detection deferred to foreground) #123

Description

@dannymcc

Symptom (on-device, Android)

The "you've been still for a while" / "Time to move" nudge often does not fire in the background during the day when it should. Instead it appears the moment the app is opened — as if a notification that should have fired earlier in the background gets flushed on foreground.

Root cause: detection is deferred to foreground (not a delivery problem)

The move/stillness nudge is a foreground-only, data-driven notification, not an OS-scheduled one. Three facts together fully explain the symptom:

  1. It is only ever evaluated on app resume. _maybeNotifyInactivity() (lib/state/app_state.dart:975) is called only from runCadenceChecks() (lib/state/app_state.dart:863), which is called only from didChangeAppLifecycleState on AppLifecycleState.resumed (lib/app.dart:73). No background isolate, headless drain, foreground-service, timer or WorkManager path ever calls it.

  2. It is presented immediately, never scheduled. The nudge goes through NotificationCenter.emitNotificationService.presentEvent_plugin.show() (immediate). It does not use zonedSchedule like the genuinely background-capable reminders (wind-down, weekly recap, hydration, AI briefings). So there is no wall-clock timer that could fire it while the app is closed — it materialises the instant the check runs, i.e. on open.

  3. Its inputs are in-memory foreground state. The condition reads _lastMovementMs / _lastWalkMs / _lastProneMs (lib/state/app_state.dart:1573-1576), which are stamped only inside _ingestLiveMags() (:1624, :1633, :1644) during live foreground IMU streaming, and are not persisted.

There is also no OS-scheduled move reminder anywhere. scheduleStandingReminders() (lib/notify/notification_center.dart:59) is misleadingly named — it only schedules wind-down, weekly recap and hydration. (The docstring at lib/notify/notification_service.dart:4 lists "move" among "scheduled nudges", which is inaccurate — the move nudge was never given a scheduled equivalent.)

Verdict: hypothesis (a), detection deferred. Opening the app is the only moment _maybeNotifyInactivity() executes; it then evaluates stillness against the in-memory movement timestamps and emits immediately — exactly the reported "fires on open" behaviour.

Shared root with #119 (smart alarm never fires)?

Likely a different root, but they rhyme. The wake alarm is band-autonomous: setAlarm() writes SET_ALARM over BLE to the band hardware (lib/state/app_state.dart:2039-2082); the band confirms via event 56 (ALARM_SET) and reports firing via 57/58/60. "The band has no independent alarm source (its alarm is always what the app last wrote, and SET_ALARM is HW-verified)." Since #119 is "the strap never buzzes", that points at a band-side SET_ALARM write/latch problem, not the phone-side foreground-detection problem here.

The one genuinely shared, higher-level fact: the app has no general-purpose periodic background scheduler. lib/sync/background_sync.dart:6-10 states it outright: "There is NO OS periodic scheduler (no WorkManager task, no BGTask): continuous capture is the kept-alive live connection in AppState." Anything that must be computed on the phone on a wall-clock cadence (like the stillness check) therefore has no place to run and defers to foreground. If #119's alarm turns out to depend on a phone-side scheduled write/notification rather than the band's autonomous alarm, it would share this exact root — that boundary needs confirming on the alarm side.

Recommended fix

Two shapes, in preference order:

  • (b) Provisional scheduled nudge (lowest risk, recommended). When movement is stamped, zonedSchedule a provisional "Time to move" for lastMovement + 2h; cancel/reschedule it on the next detected movement. The OS then fires it on time even when the app is closed, and rescheduling on new movement prevents false positives. Reuses the existing wind-down scheduleDaily/zonedSchedule plumbing.
  • (a) Background evaluation. Persist the movement timestamps to SharedPreferences and run _maybeNotifyInactivity() from a background path. This is more accurate but requires adding a periodic background trigger the app deliberately does not currently have.

Needs on-device confirmation

No physical device or Flutter/Dart SDK was available for this investigation — findings are from code inspection only. On-device verification still required: whether AndroidScheduleMode.inexactAllowWhileIdle fires acceptably on time under Doze; whether the Android foreground service keeps _lastMovementMs fresh while backgrounded (i.e. whether live IMU keeps streaming in background or only the connection is held); and the actual observed delay between the expected time and app-open.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions