All checks were successful
build-apk / build (push) Successful in 9m54s
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.
Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.
- Paid lots are filtered out of the overlay when signed out, so they are
neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
turns out to take payment another way. Null means "use the category
default", so an unset value can't be confused with an explicit false.
The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
8.7 KiB
Markdown
157 lines
8.7 KiB
Markdown
<p align="center">
|
||
<img src="app/assets/icon.png" alt="BigBrainParking — big-brain driver" width="220">
|
||
</p>
|
||
|
||
# BigBrainParking
|
||
|
||
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
|
||
built to run on **GrapheneOS** with **UnifiedPush** notifications and distributed
|
||
via **Obtainium** — no Google Play Services, no Firebase, no app store.
|
||
|
||
## Install it (users)
|
||
|
||
BigBrainParking installs and updates through **Obtainium** — no Google Play, no account.
|
||
Works on GrapheneOS. On your phone (with Obtainium installed), tap:
|
||
|
||
**➕ [Add BigBrainParking to Obtainium](obtainium://add/https://git.mowden.top/hank/BigBrainParking)**
|
||
|
||
or follow **[INSTALL.md](INSTALL.md)** for the manual steps.
|
||
|
||
## Monorepo layout
|
||
|
||
```
|
||
bigbrainparking/
|
||
├── parksmarter-client/ # Zero-dep TypeScript API client (reverse-engineered, live-verified)
|
||
├── app/ # Expo / React Native app (BigBrainParking)
|
||
└── .gitea/workflows/ # CI: build signed APK -> publish release for Obtainium
|
||
```
|
||
|
||
- **`parksmarter-client`** — the API layer. All ~40 endpoints, the custom-header auth
|
||
model, and response models (most verified against production). Runs anywhere; the app
|
||
imports it directly. See its own README for the API details.
|
||
- **`app`** — the phone app. React Native (Expo prebuild), MapLibre maps, VisionCamera QR
|
||
scanning, expo-secure-store token storage, local session-expiry reminders, and
|
||
UnifiedPush wiring.
|
||
|
||
## Features
|
||
|
||
| Feature | Status | Notes |
|
||
| --- | --- | --- |
|
||
| Phone + password login | ✅ wired | tokens in OS keystore |
|
||
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
|
||
| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** |
|
||
| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown |
|
||
| Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" |
|
||
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
|
||
| 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 |
|
||
|
||
## The city parking map
|
||
|
||
The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown &
|
||
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
||
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
||
|
||
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
|
||
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
|
||
phone's own clock, and the notification is the same foreground service every other session
|
||
uses. So tracking your time on a free city spot works with no account, no signal, no
|
||
payment, and in Anonymous Mode.
|
||
|
||
The **green city lots are the exception** — they're the map's only paid category, and paying
|
||
for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking
|
||
you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park
|
||
here" says so rather than reporting nothing nearby.) A single lot can be flipped back via
|
||
the server's `requiresAccount` field if it turns out to take payment another way.
|
||
|
||
Two ways to start:
|
||
|
||
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
|
||
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
||
stays on the map until you end the session, because "where did I leave the car" is half
|
||
the point.
|
||
- **Tap a coloured segment** — pick the block directly, no pin needed.
|
||
|
||
Either way you choose how long to track, capped at the posted limit (a 2-hour space won't
|
||
offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's
|
||
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
||
edits the local timer and says so.
|
||
|
||
The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour,
|
||
end it, and see recent ones — with no account and no network, because that is the only
|
||
place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and
|
||
failing to reach them (offline, or signed out) never hides the local half.
|
||
|
||
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
|
||
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
|
||
Because a few metres is the difference between two sides of a street, **Account → Align city
|
||
map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or
|
||
published to the server for every device if you hold the admin token.
|
||
|
||
## Privacy
|
||
|
||
BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My
|
||
location" and search; it opens on your last parking lot instead of your GPS, and ships **no**
|
||
analytics/tracking (no Segment/Amplitude/Firebase/Sentry, no ad-ID, no Google services).
|
||
For a plain-language comparison with the official app — which auto-sends your GPS on map
|
||
open and bundles that tracking stack — see
|
||
**[docs/OFFICIAL_APP_PRIVACY.md](docs/OFFICIAL_APP_PRIVACY.md)**. What reaches the API and
|
||
what never does is also spelled out in the app's **About** page.
|
||
|
||
## Build & run (dev)
|
||
|
||
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
|
||
with USB debugging.
|
||
|
||
```bash
|
||
npm install # installs both workspaces
|
||
npm run build --workspace parksmarter-client # compile the client
|
||
cd app
|
||
npx expo prebuild --platform android # generate native project
|
||
npx expo run:android # build + install a dev client
|
||
```
|
||
|
||
The app talks to prod (`apiv2.parksmarter.com`) by default — change `extra.psEnvironment`
|
||
in `app/app.json` to point elsewhere.
|
||
|
||
## Notifications on GrapheneOS
|
||
|
||
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)
|
||
|
||
Tag a release and CI builds a signed APK and publishes it to this repo's releases; your
|
||
phone's Obtainium tracks the repo and offers updates. See
|
||
[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the full server + CI + keystore setup.
|