Skip to content

v4.22 — Widget Background Check-In Sync

A habit check-in tapped on the home-screen widget now reaches the server even if you never open the app — closing the streak-loss window for good, on both Android and iOS.

Summary

The widget's interactive check-off has been fully built and symmetric across both platforms since v4.7 (iOS) and v4.11 (Android) — tap a habit on the widget and it checks off instantly, locally. But delivery to the server was entirely foreground-driven: the pending-check-in queue only drained when the app was next opened (useWidgetCheckinDrain.ts, on mount/resume). A user who checked in from the widget and never reopened the app that day lost the streak, no matter how the tap was recorded locally. Slice A of this problem (OBJ-1858, PR #1851) had already fixed which day a late-synced check-in credits — the real local tap date, not the sync date — but only within the existing streak-grace window, and only once the app was eventually reopened.

v4.22 is Slice B: it removes the app-open dependency entirely. Phase 1 was a deliberate decision gate rather than a start on implementation, because two credible approaches existed and they diverge at the auth layer. Codi's spike found the lower-infrastructure option — a Capacitor background-runner reusing the existing Clerk session — an architectural dead end: its headless JS context can't reach the secure-storage plugin holding the Clerk refresh token, so it can't avoid shipping a new on-device credential anyway, and its only workaround would have landed one in unencrypted storage. Josh signed off on the alternative: a durable, device-scoped, check-in-write-only CheckinToken, minted server-side and stored in Keychain/Keystore, with two amendments to the draft shape — long-lived (~90 days) with rotate-on-use rather than hours-lived, since the whole point of the widget is checking in for days without opening the app.

Phases 2 through 5 built to that locked shape: the backend token model and a new CheckinToken auth branch (Phase 2), app-side minting and a native secure-storage bridge (Phase 3), Android WorkManager delivery (Phase 4), and iOS BGTaskScheduler delivery parity plus on-device verification and this milestone's documentation (Phase 5). Both platforms ship the same contract — post at tap time as the primary path, a background scheduler job as retry-only backstop, a rejected or expired token as a hard stop-and-clear rather than a silent retry, and no new deduplication authority beyond the server's existing per-day idempotency. Two real review-round findings — an offline-sign-out stale-token security gap and a device-id backfill gap that would have silently disabled background delivery on existing devices — were caught and fixed before merge, not after.

One risk was accepted rather than resolved: whether iOS's BGTaskScheduler genuinely invokes its registered retry handler while the app is fully closed could not be proven in this environment, for lack of a physical iOS device. Josh explicitly accepted shipping on the strength of the tap-time-primary path — which is structurally guaranteed by iOS's own App-Extension sandboxing and carries the real reliability — choosing to monitor the scheduler-retry path's real-world behavior via production telemetry after launch instead of blocking on unavailable hardware.

Goal

A habit check-in tapped on the home-screen widget reaches the server without the user ever opening the app — closing the streak-loss window entirely, on both Android and iOS.

Scope — What Shipped

  • CheckinToken model + auth branch (rails_api/app/models/checkin_token.rb, graphql_controller.rb#current_user) — a PublicRecord+public_id+acts_as_paranoid DB-backed credential, check-in-write-scoped only, device-bound, ~90-day TTL, server-revocable. A new SessionToken: CheckinToken <deviceId>:<token> header scheme sits alongside the existing Clerk JWT, MagicLink, and Fallback branches.
  • Mint / rotate-on-use / revoke Interaction::Base classes (UserIdentity::MintCheckinToken/RotateCheckinToken/RevokeCheckinToken) — mint on sign-in behind widget_background_sync_enabled (fail-closed: returns nil when the flag is off), rotate-on-use on every successful background-authenticated checkInHabit, revoke bulk-clears all of a device's tokens on sign-out.
  • useCheckinToken.ts + native bridge push — mints/caches the token app-side, pushes it to iOS Keychain / Android Keystore-backed EncryptedSharedPreferences via new WidgetBridge.setCheckinToken/clearCheckinToken methods, always re-pushes on an already-cached token so a pre-Phase-4 token gets its device id backfilled.
  • Android background deliveryHomeWidgetProvider.onReceive enqueues an expedited WorkManager job at tap (primary); a periodic WorkManager job (CheckinDeliveryWorker, androidx.work:work-runtime:2.10.0) drains PENDING_CHECKINS_LOCK as retry-only. Verified on real hardware: a Pixel emulator, am force-stop full kill, dumpsys jobscheduler + logcat proof of genuine post-kill execution.
  • iOS background deliveryCheckInHabitIntent.perform() POSTs at tap time inside the widget-extension process (a guaranteed live window by construction of App Extension architecture); a BGTaskScheduler-registered CheckinDeliveryTask drains the App Group queue as retry-only. New Info.plist keys (BGTaskSchedulerPermittedIdentifiers, UIBackgroundModes), no new entitlement.
  • Shared delivery contract, both platformsCheckinToken <deviceId>:<token> header, remove-only-sent on drain, hard stop-and-clear (never a silent retry) on a rejected/expired token, honour the existing PENDING_CHECKINS_LOCK/App Group lock, reuse the existing checkInHabit mutation and the server's per-day idempotency — no second dedup authority anywhere in the stack.
  • widget_background_sync_enabled feature flag — server-side only (PostHog, evaluated inside the mint interaction). Flag-off is byte-identical to pre-milestone behavior on both platforms: no token minted, no background job registered, tap-time delivery silently no-ops back to the existing foreground drain.
  • Docsdocs/features/home-widgets.md (Background check-in delivery section: architecture, both delivery paths, idempotency, flag, the accepted iOS risk), docs/architecture/authentication.md (Check-in Token Authentication: header format, mint/rotate/revoke lifecycle, device binding), docs/operations/mobile-builds.md (WorkManager/BGTaskScheduler build setup, new Info.plist keys, GraphQL endpoint resolution for native).

Phases

PhaseNameStatusPlansHighlights
1Architecture decision gateShipped1Spiked and rejected the Capacitor background-runner option (can't reach the Clerk refresh token); locked the device-scoped CheckinToken approach with Josh's TTL/device-binding amendments
2Auth foundationShipped1CheckinToken model, CheckinToken <token> auth branch, mint/rotate/revoke Interactions, fail-closed behind the flag
3App-side credential mint/refresh + native bridge pushShipped1useCheckinToken.ts, Keychain/Keystore storage, WidgetBridge push; fixed a security BLOCKER (offline-sign-out stale-token reuse) in review
4Android WorkManager background deliveryShipped1Expedited tap-time job + periodic retry job; fixed a MAJOR (device-id backfill) in review; verified on real hardware
5iOS delivery + verification + milestone docsShipped1BGTaskScheduler retry parity, on-device verification (accepted risk on live-fire proof), milestone-close docs, merged via the milestone-close integration PR

Key Decisions

  • Reject the Capacitor background-runner, adopt a durable device-scoped token — the spike collapsed what looked like a security-vs-reliability tradeoff: the background-runner's headless context genuinely cannot reach the Clerk refresh token, so it can't deliver its "no new credential" premise either. There was no real choice left once that was proven.
  • ~90-day TTL with rotate-on-use, not hours-lived (Josh amendment) — an hours-lived token expires in exactly the multi-day-no-app-open scenario this milestone exists to fix, and re-minting needs a foreground session, so the failure would be silent and correlated with the users who need background delivery most.
  • Tap-time-primary, scheduler-retry-only, on both platforms (Josh, at the decision gate) — designs around iOS's known BGTask unreliability tail up front rather than discovering it at milestone close; the widget-extension process gives iOS a genuinely live execution window at tap time that doesn't depend on OS scheduler discretion.
  • No new dedup authority — reuse the server's per-day idempotency — the v4.11 lesson (an append landing between a drain's read and its clear was silently wiped) is honored here by keeping remove-only-sent semantics and never building a second source of truth for "was this check-in already recorded."
  • A rejected or expired token is a hard stop-and-clear, never a silent retry — carried forward from the Phase 3 offline-sign-out security fix into both platforms' native delivery code, so a revoked credential can't be quietly retried with itself.
  • Accept the iOS live-fire verification gap and ship (Josh, 2026-07-29) — the tap-time-primary path is structurally guaranteed and independently corroborated; the scheduler-retry path's actual OS-invocation is unprovable without a physical device the crew didn't have. Monitor via production telemetry post-launch rather than block on unavailable hardware.
  • Phase 4/5 split by platform, not by delivery-vs-verify — reconciled mid-milestone to match the existing per-platform Multica child issues; the tap-time-primary + scheduler-retry architecture is unchanged, it's applied once per platform instead of once across both.

Requirements Coverage

5 / 5 phase deliverables satisfied (per the Milestone Audit; this milestone has no standalone REQUIREMENTS.md — coverage is derived from the ROADMAP's own phase checklist and fidelity contract per this repo's planning convention).

CategoryCountStatus
Decision gate + ADR (Phase 1)1Satisfied
Backend auth foundation (Phase 2)1Satisfied
App-side credential bridge (Phase 3)1Satisfied
Android background delivery (Phase 4)1Satisfied
iOS delivery + verification + docs (Phase 5)1Satisfied — with one accepted, documented risk on live-fire verification

Outcomes

A habit check-in tapped from the home-screen widget on Android now reliably reaches the server even if the app is never reopened — proven on real hardware via a full app kill and OS-level JobScheduler verification. On iOS, the same tap-time path runs inside the widget-extension process's own guaranteed live window, so the check-in reaches the server before the background scheduler is ever needed in the common case; the scheduler-retry backstop's live-fire behavior while the app is fully closed remains unverified pending physical-device access and production telemetry. Both platforms fail closed: with widget_background_sync_enabled off, behavior is byte-identical to before this milestone. No production rollout percentage has been set yet — that remains a separate, later product decision, consistent with how prior widget milestones (v4.11) treated rollout.

Tech Debt

  • (Phase 5, accepted risk) iOS BGTaskScheduler scheduler-retry live-fire behavior is unverified pre-launch — Josh accepted shipping on the tap-time-primary path's structural guarantee, to be monitored via production telemetry.
  • (Phase 5, open follow-up) No delivery success/failure telemetry breadcrumb exists yet in iOS CheckinDeliveryTaskScheduling.performDelivery() or Android CheckinDeliveryWorker.doWork() — this is the actual mechanism the accepted-risk decision depends on, and Vicki flagged it as a tracked follow-up rather than a passing mention.
  • (carried, pre-existing) Neither platform's GRAPHQL_BASE_URL override is wired into a CI workflow — every CI-built mobile artifact points at production unless set by hand. Not introduced by this milestone.
  • aa61589d3 — [Riley] chore(release): merge v4.22 Widget Background Check-In Sync to master (OBJ-1860) (#1866) — milestone-complete commit
  • dc2b3be6b — [Dori] docs(widget): document v4.22 background check-in delivery + CheckinToken lifecycle (OBJ-1865)
  • 039bcaa29 — [Codi] feat(widget): Android WorkManager background check-in delivery (OBJ-1864) (#1859)
  • 5adee1cc0 — [Codi] feat(auth): app-side check-in token mint/refresh + native bridge push (OBJ-1863) (#1857)
  • a82f54831 — [Codi] feat(auth): device-scoped check-in token foundation (OBJ-1862) (#1855)

Last updated: 2026-07-30

Loading…