feat: hold Android push in a foreground service #88

Merged
nalum merged 4 commits from feat/push-foreground-service into main 2026-08-16 12:41:04 +00:00
Owner

Closes #59. Extends ADR-0030.

Promotes the Subscribe consumer from #58 into a foreground service, so Android push arrives within seconds even with the app closed — the deferred half of ADR-0030.

What

  • PushService — a foreground service that owns the Subscribe stream and renders each row via Notifier. Silent, minimized persistent notification (IMPORTANCE_MIN channel, "Watching for updates"). START_STICKY so the OS restarts it after a kill; idempotent start so re-arming doesn't stack streams.
  • Foreground type specialUse (with a dataSync fallback for pre-34). dataSync carries a per-day cap on Android 15 that would break the overnight-idle case; specialUse fits a long-lived socket and we self-distribute (no Play review).
  • Started from the foreground (signed-in shell) — a background start is barred on Android 12+. Stopped on sign-out (Graph.cancelSessionWork).
  • Reconnect + token freshness — the stream's redial loop already rides out airplane toggles / server restarts / Wi-Fi↔mobile. It now refreshes the session (an authed catch-up read) before each dial, so an idle stream whose access token expired overnight reconnects itself instead of waiting for the backstop poll.
  • Battery-optimization exemption, asked once (ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, device-level battery_prompt_seen flag) — materially improves survival on stock Android.
  • The 15-minute InboxPollWorker stays as the last-resort backstop for OEMs (Xiaomi/Huawei/Samsung) that kill foreground services anyway.

The live-stream launch moves out of MainActivity (activity-scoped, died on close) and into the service.

Design decisions (agreed)

  • Always on while signed in — no user toggle (a toggle is a way to silently break push).
  • Keep the poll backstop.
    Both recorded in the ADR-0030 update.

Verification

make check green; :app:assembleDebug (manifest merge for the FGS type/perms), app+data unit tests, and compileDebugKotlin all pass.

⚠️ The DoD is device-only and I cannot run it: closed-app delivery within seconds on a device left idle overnight, and recovery from airplane toggles / server restart / Wi-Fi↔mobile — needs a physical device over a multi-hour window. OEM battery-killer behaviour is inherently per-device. Please verify on the phone before merge.

Stacking

Stacked for fast-forward on feat/self-contained-android-push (#87). Merge order: #82 → #79 → #80 → #81 → #84 → #85 → #86 → #87 → this.

🤖 Generated with Claude Code

Closes #59. Extends ADR-0030. Promotes the `Subscribe` consumer from #58 into a **foreground service**, so Android push arrives within seconds even with the app closed — the deferred half of ADR-0030. ## What - **`PushService`** — a foreground service that owns the `Subscribe` stream and renders each row via `Notifier`. Silent, minimized persistent notification (`IMPORTANCE_MIN` channel, "Watching for updates"). `START_STICKY` so the OS restarts it after a kill; idempotent start so re-arming doesn't stack streams. - **Foreground type `specialUse`** (with a `dataSync` fallback for pre-34). `dataSync` carries a per-day cap on Android 15 that would break the overnight-idle case; `specialUse` fits a long-lived socket and we self-distribute (no Play review). - **Started from the foreground** (signed-in shell) — a background start is barred on Android 12+. Stopped on sign-out (`Graph.cancelSessionWork`). - **Reconnect + token freshness** — the stream's redial loop already rides out airplane toggles / server restarts / Wi-Fi↔mobile. It now refreshes the session (an authed catch-up read) **before each dial**, so an idle stream whose access token expired overnight reconnects itself instead of waiting for the backstop poll. - **Battery-optimization exemption, asked once** (`ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, device-level `battery_prompt_seen` flag) — materially improves survival on stock Android. - **The 15-minute `InboxPollWorker` stays** as the last-resort backstop for OEMs (Xiaomi/Huawei/Samsung) that kill foreground services anyway. The live-stream launch moves out of `MainActivity` (activity-scoped, died on close) and into the service. ## Design decisions (agreed) - Always on while signed in — no user toggle (a toggle is a way to silently break push). - Keep the poll backstop. Both recorded in the ADR-0030 update. ## Verification `make check` green; `:app:assembleDebug` (manifest merge for the FGS type/perms), app+data unit tests, and `compileDebugKotlin` all pass. ⚠️ **The DoD is device-only and I cannot run it:** closed-app delivery within seconds on a device left **idle overnight**, and recovery from airplane toggles / server restart / Wi-Fi↔mobile — needs a physical device over a multi-hour window. OEM battery-killer behaviour is inherently per-device. Please verify on the phone before merge. ## Stacking Stacked for fast-forward on `feat/self-contained-android-push` (#87). Merge order: **#82 → #79 → #80 → #81 → #84 → #85 → #86 → #87 → this**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(android): hold push in a foreground service
All checks were successful
check / commits (pull_request) Successful in 5s
check / web (pull_request) Successful in 1m37s
check / go (pull_request) Successful in 2m44s
check / report (pull_request) Successful in 4s
android / build (pull_request) Successful in 5m22s
android / report (pull_request) Successful in 3s
d14d83963c

Test report

Suite Tests Result Skipped
Unit 1330 ✅ pass 1
Integration 83 ✅ pass —

Coverage: 27.6%

Updated by the check workflow · commit f189c2029c

<!-- ci-test-report --> ## Test report | Suite | Tests | Result | Skipped | | --- | --: | --- | --: | | Unit | 1330 | ✅ pass | 1 | | Integration | 83 | ✅ pass | — | **Coverage:** 27.6% <sub>Updated by the check workflow · commit f189c2029cc7903f3ef2a554af20a1b1ca774522</sub>

Android test report

Suite Tests Result Skipped
Unit (debug) 31 ✅ pass 0

Updated by the android workflow · commit f189c2029c

<!-- android-test-report --> ## Android test report | Suite | Tests | Result | Skipped | | --- | --: | --- | --: | | Unit (debug) | 31 | ✅ pass | 0 | <sub>Updated by the android workflow · commit f189c2029cc7903f3ef2a554af20a1b1ca774522</sub>
On-device verify of #59 found the held stream dead almost always: two
layered timeouts killed it. connect-kotlin's default timeoutOracle
deadlines every call at 10 seconds — held-open streams included — so
Subscribe died at exactly 10s and redialed at 15; the shared OkHttp
client's 30-second read timeout would have killed whatever survived.
Notifications reached the in-app inbox via the catch-up refresh but
almost never the system tray, because the catch-up was silent.

Streams now ride their own transport: no per-call deadline (the oracle
answers null), no read timeout, HTTP/2 pings every 30 seconds so a
dead link is still noticed. Watch moves onto it too. And the catch-up
before each dial now speaks: rows minted after the stream last stood
(by server clock, so device skew cannot mute or replay) raise the same
system notification a streamed row would — a reconnect gap no longer
swallows what happened during it.
fix(android): rebuild the member graph before holding the stream
All checks were successful
check / commits (pull_request) Successful in 6s
check / go (pull_request) Successful in 2m52s
check / report (pull_request) Successful in 6s
check / web (pull_request) Successful in 1m59s
android / build (pull_request) Successful in 6m0s
android / report (pull_request) Successful in 2s
f189c2029c
A START_STICKY restart (or any process the OS brings back without the
Activity) reached the foreground service with only the Application
graph, which is built without the signed-in member — Graph.inbox was
null, ?.live() no-oped, and the service sat behind its 'watching for
updates' notice holding nothing. The Application now rebuilds with the
stored member so every process starts member-shaped, and the service
rebuilds for itself if it still finds the graph bare — whichever comes
back first, the stream it holds is real.
nalum force-pushed feat/push-foreground-service from f189c2029c
All checks were successful
check / commits (pull_request) Successful in 6s
check / go (pull_request) Successful in 2m52s
check / report (pull_request) Successful in 6s
check / web (pull_request) Successful in 1m59s
android / build (pull_request) Successful in 6m0s
android / report (pull_request) Successful in 2s
to 279d8d8f3a
Some checks failed
android / build (push) Has been cancelled
android / report (push) Has been cancelled
check / commits (push) Has been cancelled
check / go (push) Has been cancelled
check / report (push) Has been cancelled
check / web (push) Has been cancelled
tag / tag (push) Has been cancelled
check / commits (pull_request) Has been cancelled
check / go (pull_request) Has been cancelled
check / report (pull_request) Has been cancelled
check / web (pull_request) Has been cancelled
android / build (pull_request) Has been cancelled
android / report (pull_request) Has been cancelled
2026-08-16 12:40:38 +00:00
Compare
nalum changed target branch from feat/self-contained-android-push to main 2026-08-16 12:40:58 +00:00
nalum merged commit 279d8d8f3a into main 2026-08-16 12:41:04 +00:00
nalum deleted branch feat/push-foreground-service 2026-08-16 12:41:04 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app!88
No description provided.