v0.5.0: persistent parking notification — foreground service, End/Extend
All checks were successful
build-apk / build (push) Successful in 28m55s

The ongoing countdown now appears whenever a car is parked, paid or free,
and sticks around the way ntfy's does on GrapheneOS.

Why it wasn't showing at all on a paid park: StartSessionScreen carried its
own stricter copy of parseApiTime that demanded MM-DD-YYYY + AM/PM. The
dual-format fix from deb78bf only landed in sessionStatus.ts, so when the
API returned the other format the parser returned null and the notification
was simply never posted. There is now one parser (api/parseTime.ts), and the
failure logs instead of going silent.

Persistence: a plain notify() was never enough — Android 14+ lets the user
swipe an ongoing notification away, and nothing brought it back after a
reboot. BbpSessionService is a real foreground service (type specialUse) that
owns the notification, plus a BOOT_COMPLETED receiver to restore it; specialUse
is one of the types Android 14/15 still allow to start from BOOT_COMPLETED.
The service's life is exactly the session's life: it stops itself, removing
the notification, on End, at expiry, or when there's no session to show.

Buttons: paid sessions previously got no actions at all (only free check-ins
did). Both now get End and Extend/Pay. End stops tracking — honest about the
fact that ParkSmarter has no stop-session endpoint, so bought time keeps
running at the meter. Extend opens the purchase screen for that exact zone.

The active session (with its Zone) is persisted locally, so the countdown and
Extend survive reboot, offline, and Anonymous Mode instead of depending on a
round-trip. sessionStatus.ts + features/checkin collapse into one owner,
features/session/activeParking — one record, one notification.

Verified: :bbp-notify:compileDebugKotlin passes, and the merged app manifest
carries the service (specialUse + FGS subtype property), both receivers, and
the FOREGROUND_SERVICE/SPECIAL_USE/RECEIVE_BOOT_COMPLETED permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 20:10:54 +00:00
parent 796674bca4
commit ad55559f55
21 changed files with 897 additions and 564 deletions

View file

@ -44,6 +44,7 @@ bigbrainparking/
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
| Active / past sessions | ✅ wired | list views + tap for full receipt |
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
@ -75,12 +76,35 @@ in `app/app.json` to point elsewhere.
## Notifications on GrapheneOS
Session-expiry reminders are scheduled **entirely on-device** from each session's end time
(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
Services. They work fully offline. Configure the lead time (default 15 min) in
**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
*future* server-initiated messages; nothing time-based needs it.
Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
any *future* server-initiated messages; nothing time-based needs it.
**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
a free check-in — a persistent notification shows the time left and ticks down, with
**End** and **Extend** buttons. It is held up by a real **foreground service**
(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
way ntfy's does: it survives the app being killed, can't be swiped away, and a
`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
session's life — it stops itself, removing the notification, on **End**, when the meter
runs out, or whenever there's no session to show.
The countdown itself costs no battery: the end time is handed to Android as a
[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
and the system redraws the ticking text with the app closed and no timer of its own.
- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
endpoint, so time you already bought keeps running at the meter either way.
- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
zone. The active session is stored locally *with its zone*, so this works offline, in
Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
actually goes through.
**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
for the ongoing countdown.
## Distribution via Obtainium (self-hosted)