new changes maps
This commit is contained in:
99
docs/MAPS.md
Normal file
99
docs/MAPS.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Maps & routing
|
||||
|
||||
Fully open source, no API key, no account, no card.
|
||||
|
||||
| Concern | Technology | Cost |
|
||||
| --- | --- | --- |
|
||||
| Rendering | [MapLibre GL](https://maplibre.org) (BSD) | free |
|
||||
| Tiles | [OpenFreeMap](https://openfreemap.org) — OSM data, MIT | free, no key, no limits |
|
||||
| Routing | [OSRM](https://project-osrm.org) (BSD) | free, self-hostable |
|
||||
|
||||
There is no dependency on Google Maps: no Maps SDK, no Directions API, no
|
||||
`com.google.android.geo.API_KEY` in the manifest, and no credential of any kind
|
||||
shipped in the app.
|
||||
|
||||
## Why
|
||||
|
||||
Google's mobile Maps *SDK* is free and unlimited — the bill came from the
|
||||
**Directions API** at $5 per 1,000 requests above a 10,000/month free tier. Two
|
||||
things made it worse: the multi-stop screen issued one Directions call *per
|
||||
route segment*, and the API key was hardcoded in five places (including
|
||||
`AndroidManifest.xml`), unrestricted because the routing calls went out from the
|
||||
device as web-service requests.
|
||||
|
||||
Commercial alternatives were considered and rejected because they all want an
|
||||
account and a payment method. The open-source stack has neither.
|
||||
|
||||
## Configuration
|
||||
|
||||
Nothing is required to run the app — the defaults work out of the box:
|
||||
|
||||
```sh
|
||||
flutter run
|
||||
```
|
||||
|
||||
Both endpoints are overridable at build time, so production can point at
|
||||
self-hosted infrastructure without a code change:
|
||||
|
||||
```sh
|
||||
flutter build apk \
|
||||
--dart-define=OSRM_BASE_URL=https://osrm.your-domain.in \
|
||||
--dart-define=MAP_STYLE_URL=https://tiles.your-domain.in/styles/liberty
|
||||
```
|
||||
|
||||
> **Before production**, stand up your own OSRM. The default
|
||||
> (`routing.openstreetmap.de`) is the FOSSGIS community server — free and
|
||||
> keyless, but donation-funded with a fair-use policy that a delivery fleet
|
||||
> would abuse. See [`deploy/osrm/README.md`](../deploy/osrm/README.md); the
|
||||
> Coimbatore region needs only a 0.52 GB extract.
|
||||
>
|
||||
> Tiles do **not** need self-hosting — OpenFreeMap's public instance has no key
|
||||
> and no request limits, and permits commercial use under MIT with attribution
|
||||
> (which MapLibre renders automatically).
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `lib/services/maps/map_config.dart` | Tile style + routing endpoints |
|
||||
| `lib/services/maps/routing_service.dart` | OSRM client, polyline decoding, response cache |
|
||||
| `lib/services/maps/map_marker_icons.dart` | Canvas-drawn marker bitmaps (MapLibre has no built-in pins) |
|
||||
| `lib/services/maps/nearle_map.dart` | The `NearleMap` widget — declarative markers/polylines over MapLibre |
|
||||
|
||||
Provider choice is confined to the first two files. The four map screens talk
|
||||
only to `NearleMap` and `RoutingService`, so swapping backends again would not
|
||||
touch UI code.
|
||||
|
||||
### OSRM notes
|
||||
|
||||
- Coordinates go in the URL path as **`lon,lat`** — the reverse of most APIs.
|
||||
- `overview=full&geometries=polyline` returns an encoded polyline at
|
||||
**precision 5**. OSRM's `polyline6` option is precision 6; pass
|
||||
`precision: 6` to `decodePolyline` if you ever switch.
|
||||
- `_parse()` is deliberately tolerant — plain-number *or* `{"value": n}`
|
||||
distances, several geometry key names, GeoJSON `LineString` — so a proxy or an
|
||||
OSRM-compatible alternative (Valhalla, GraphHopper) drops in without changes.
|
||||
|
||||
## Cost controls
|
||||
|
||||
These mattered when routing was billed and still cut latency and load:
|
||||
|
||||
- **One call per route, not per leg.** The multi-stop screen passes intermediate
|
||||
stops as additional coordinates in a single OSRM request. It previously issued
|
||||
one request per consecutive pair, so an N-stop route cost N calls every time
|
||||
the screen opened.
|
||||
- **Response cache.** `RoutingService` caches by origin/destination rounded to
|
||||
~11 m for 30 minutes, so repeated quotes for the same delivery reuse one call.
|
||||
- **Rendering makes no routing calls at all.**
|
||||
|
||||
## Known gaps vs. the old Google implementation
|
||||
|
||||
- **Info windows.** MapLibre has no info-window concept. Marker taps still fire
|
||||
(`NearleMapMarker.onTap` drives the customer sheet on the multi-stop screen),
|
||||
but the old tap-to-show-title bubbles are gone.
|
||||
- **Traffic and 3D buildings.** `trafficEnabled` / `buildingsEnabled` on the
|
||||
navigation screen had no MapLibre equivalent and were dropped. OSRM has no
|
||||
live-traffic model either — ETAs are free-flow estimates.
|
||||
- **Turn-by-turn navigation is unchanged.** It still hands off to whatever
|
||||
navigation app is installed via a `google.navigation:` / maps URL intent. That
|
||||
is an OS intent, not an API — no key, no SDK, no billing.
|
||||
620
docs/PORTFOLIO.md
Normal file
620
docs/PORTFOLIO.md
Normal file
@@ -0,0 +1,620 @@
|
||||
# Nearle Rider (`com.nearle.partner`) — Portfolio Technical Summary
|
||||
|
||||
A production Android delivery-rider app for a last-mile logistics operation in
|
||||
Coimbatore, India. ~30,000 lines of Dart plus native Kotlin. It's the app a
|
||||
delivery rider has open on the handlebar for an entire shift.
|
||||
|
||||
There are really **six** distinct systems in here, not one app. Taken in order of
|
||||
engineering weight.
|
||||
|
||||
---
|
||||
|
||||
## 1. The GPS Pipeline — Trusted Distance Measurement
|
||||
|
||||
### What it is
|
||||
|
||||
Riders are paid by the kilometre. The formula lives in `deliveries_controller.dart`:
|
||||
|
||||
```
|
||||
ridercharges = distance_travelled_km × fuelcharge_rate
|
||||
```
|
||||
|
||||
That single line is why this subsystem exists. Consumer phone GPS is noisy: it
|
||||
drifts several metres while stationary, jumps hundreds of metres when switching
|
||||
between cell-tower and satellite fixes, degrades under power-saving mode, and can
|
||||
be trivially faked with a mock-location app. Every one of those failure modes is
|
||||
*money* — noise inflates payouts, jumps inflate payouts spectacularly, and
|
||||
spoofing is outright fraud.
|
||||
|
||||
So the problem isn't "track the rider." It's: **produce a distance number that is
|
||||
defensible enough to pay against, from a sensor that lies.**
|
||||
|
||||
### How it's built
|
||||
|
||||
```
|
||||
Android GPS (bestForNavigation, distanceFilter: 0)
|
||||
│ raw Position stream, ~1 Hz
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Gate 1: isMocked? → drop │ anti-fraud
|
||||
│ Gate 2: accuracy > 50 m? → drop │ quality
|
||||
│ Gate 3: <3 s since last? → drop │ rate limit
|
||||
│ Gate 4: _isProcessing? → drop │ re-entrancy mutex
|
||||
└──────────────────────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 4-D Kalman filter │
|
||||
│ state = [lat, lng, v_lat, v_lng] │
|
||||
│ predict(dt) → update(measurement) │
|
||||
└──────────────────────────────────────────┘
|
||||
│ smoothed (lat, lng)
|
||||
├──────────────────────────────► MQTT nearle/riders/{id}/location
|
||||
│ (QoS 0 — fire and forget)
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Per-delivery odometer, in SharedPrefs │
|
||||
│ Haversine(anchor → now) │
|
||||
│ Gate 5: implied speed > 120 km/h │
|
||||
│ AND jump > 200 m → move anchor,│
|
||||
│ DON'T count the metres │
|
||||
│ Gate 6: delta < 5 m → skip, DON'T move │
|
||||
│ anchor (noise floor) │
|
||||
│ else: cumulativeKm += delta │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The approach, step by step
|
||||
|
||||
**The Kalman filter** (`lib/utils/kalman_filter.dart`) is hand-implemented — no
|
||||
linear-algebra dependency, just nested `List<List<double>>` and explicitly
|
||||
unrolled matrix helpers (`_multiply2x4_4x2`, `_inverse2x2`, and so on). State is
|
||||
4-dimensional: position *and* velocity in each axis. The measurement matrix `H`
|
||||
is 2×4 because GPS only observes position; velocity is inferred from how position
|
||||
evolves. Measurement noise `R` is set to `1e-5` degrees² (~1 m), encoding "GPS is
|
||||
accurate to roughly 5–10 m." `dt` is fed in live from wall-clock deltas, so the
|
||||
filter behaves correctly whether samples arrive 3 seconds apart (foreground) or 30
|
||||
seconds apart (background).
|
||||
|
||||
The payload sends **both** the filtered and the raw coordinates (`lat`/`lng`
|
||||
alongside `raw_lat`/`raw_lng`). That's a good call: dispatch sees the smooth
|
||||
track, but the unfiltered truth is preserved server-side for audit or for
|
||||
re-deriving with different parameters later.
|
||||
|
||||
**The two gates are asymmetric on purpose, and this is the cleverest bit.** When
|
||||
GPS jumps implausibly far, the code moves the anchor point but does *not* add the
|
||||
distance — the phantom kilometres are discarded. When movement is *below* 5 m, it
|
||||
does the opposite: it skips *without* moving the anchor. If it moved the anchor on
|
||||
tiny deltas, a phone sitting still would accumulate GPS jitter forever and
|
||||
silently print money. Getting both directions right requires actually reasoning
|
||||
about the failure mode rather than just clamping values.
|
||||
|
||||
The jump threshold is also velocity-based rather than fixed:
|
||||
`max_realistic = elapsed_seconds × 33.3 m/s` (120 km/h). A 200 m step in 3 seconds
|
||||
is fraud; the same 200 m step across a 60-second background interval is a normal
|
||||
motorcycle. A fixed distance threshold would have to choose one and be wrong for
|
||||
the other.
|
||||
|
||||
### The tricky case: two isolates, one odometer
|
||||
|
||||
Flutter's foreground service runs in a **separate Dart isolate** with its own
|
||||
memory. So there are two independent copies of this pipeline —
|
||||
`LiveTrackingService` (main isolate, 3 s cadence) and `_BackgroundRiderLog`
|
||||
(service isolate, 30 s cadence) — and both want to increment the same odometer.
|
||||
Naively, every metre gets counted twice and every rider gets paid double.
|
||||
|
||||
The coordination is a **heartbeat in shared storage**:
|
||||
|
||||
```
|
||||
main isolate ──writes──► live_tracking_last_update_ms ──reads──► service isolate
|
||||
│
|
||||
if (now − heartbeat < 10 s) │
|
||||
→ main is alive, stand down
|
||||
else │
|
||||
→ main is dormant, take over
|
||||
```
|
||||
|
||||
The main isolate stamps a timestamp on every update. The background isolate reads
|
||||
it and accumulates *only* if the stamp is stale by 10 seconds — meaning the app is
|
||||
backgrounded, screen-off, or the OS has frozen the main isolate. Handover in both
|
||||
directions is automatic and requires no signalling. Since `SharedPreferences`
|
||||
caches in memory per isolate, the background side calls `prefs.reload()` before
|
||||
reading to force a disk re-read.
|
||||
|
||||
*This is a lease/lock pattern implemented over the only shared medium two Dart
|
||||
isolates reliably have.* It's the single most non-obvious piece of engineering in
|
||||
the codebase.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **`SharedPreferences` as the IPC channel.** It's key–value storage, not a
|
||||
database — no transactions, no atomic increments. Two writers to the same key
|
||||
could theoretically interleave. The 10-second lease makes that window vanishingly
|
||||
small in practice, and it buys zero extra dependencies plus survival across
|
||||
process death. A proper answer would be SQLite with a write-ahead log; this is
|
||||
the pragmatic one.
|
||||
- **Cumulative distance beats point-to-point.** At delivery confirmation the code
|
||||
prefers the tracked odometer over any recomputed origin→destination distance,
|
||||
precisely because the odometer reflects the path actually ridden — detours,
|
||||
one-ways, wrong turns and all.
|
||||
- Reading the code honestly: the process-noise matrix `Q` has entries at `[2][0]`
|
||||
and `[3][1]` that make it non-symmetric, which is unusual for a covariance
|
||||
matrix. It doesn't destabilise the filter at these magnitudes, but it looks like
|
||||
a transcription slip rather than a deliberate choice.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Maps & Routing Migration — Removing Google Entirely
|
||||
|
||||
### What it is
|
||||
|
||||
The app originally used Google Maps SDK for rendering and the Google Directions
|
||||
API for road routes. It was billing painfully. The rebuild replaced the entire
|
||||
stack with open-source components — **no API key of any kind ships in the app
|
||||
anymore.**
|
||||
|
||||
### The diagnosis (this is the interesting part)
|
||||
|
||||
The obvious assumption — "map rendering is expensive" — is wrong. Google's mobile
|
||||
Maps *SDK* is free and unlimited. The bill came from **Directions**, at $5 per
|
||||
1,000 calls above a 10,000/month free tier. Two things amplified it:
|
||||
|
||||
1. The multi-stop screen issued **one Directions request per consecutive pair of
|
||||
stops**. Opening a 6-stop route cost 5 billable requests — *every time the
|
||||
screen opened*, and riders open it constantly.
|
||||
2. The API key was hardcoded in five places including `AndroidManifest.xml`.
|
||||
Because `flutter_polyline_points` called the Directions **web service** from the
|
||||
device, the key could not carry an Android-app restriction — Google only
|
||||
supports that for SDK calls. Anyone who unzipped the APK had an unrestricted,
|
||||
billable key.
|
||||
|
||||
So the fix that actually saves money is replacing *routing*, not rendering. That
|
||||
reframing is what made the migration tractable.
|
||||
|
||||
### The replacement stack
|
||||
|
||||
| Concern | Was | Now | Cost |
|
||||
| --- | --- | --- | --- |
|
||||
| Rendering | Google Maps SDK | **MapLibre GL** (BSD) | free |
|
||||
| Tiles | Google | **OpenFreeMap** (OSM data, MIT) | free, keyless, no limits |
|
||||
| Routing | Directions API | **OSRM** (BSD, self-hostable) | free |
|
||||
| Turn-by-turn | — | OS intent handoff (`google.navigation:`) | free — it's an intent, not an API |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
4 map screens (map, multi_map, nav, homepage)
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
▼ ▼
|
||||
NearleMap widget RoutingService
|
||||
(declarative markers/lines) (OSRM client + cache)
|
||||
│ │
|
||||
├── MapMarkerIcons ──┐ │
|
||||
│ (Canvas → PNG) │ │
|
||||
▼ ▼ ▼
|
||||
maplibre_gl MapConfig ──── endpoint URLs
|
||||
│ (String.fromEnvironment)
|
||||
▼ ▼
|
||||
OpenFreeMap tiles OSRM /route/v1/driving/…
|
||||
```
|
||||
|
||||
The provider choice is confined to **two files** (`map_config.dart`,
|
||||
`routing_service.dart`). No UI file names a vendor. Swapping backends again
|
||||
wouldn't touch a screen.
|
||||
|
||||
### How each hard part was solved
|
||||
|
||||
**One call, not N.** OSRM accepts every stop as a coordinate in a single request
|
||||
path: `lon,lat;lon,lat;lon,lat`. The multi-stop screen now passes intermediate
|
||||
stops as `waypoints` in one call. N requests → 1.
|
||||
|
||||
**Response caching.** `RoutingService` keys its cache on coordinates rounded to 4
|
||||
decimal places (~11 m), with a 30-minute TTL and a 120-entry cap evicted
|
||||
oldest-first. Repeated quotes for the same delivery reuse one response.
|
||||
|
||||
**A deliberately tolerant parser.** `_parse()` accepts distances as plain numbers
|
||||
*or* as `{"value": n}` objects, falls back from route-level totals to summing legs,
|
||||
and hunts for geometry under `geometry` / `overview_polyline` / `polyline`, nested
|
||||
`{"points": …}`, GeoJSON `coordinates`, and finally by stitching per-step
|
||||
geometries. This is defensive work that lets a proxy, a self-hosted build, or an
|
||||
OSRM-compatible alternative (Valhalla, GraphHopper) drop in without a code change.
|
||||
|
||||
**The polyline codec.** Both Google and OSRM encode geometry using Google's
|
||||
Encoded Polyline Algorithm — a variable-length, zigzag-encoded, base-64-offset
|
||||
delta format. It's implemented from scratch (`decodePolyline`) with the precision
|
||||
parameterised, because OSRM's default `geometries=polyline` is precision 5 while
|
||||
its `polyline6` option is precision 6. Get that wrong and routes render as a tiny
|
||||
smudge near the equator.
|
||||
|
||||
**The lon,lat trap.** OSRM takes coordinates as `lon,lat` in the URL path —
|
||||
reversed from nearly every other geo API. Swapping them silently returns routes in
|
||||
the wrong hemisphere rather than erroring. The smoke test asserts route endpoints
|
||||
land within 0.02° of the requested points specifically to catch this.
|
||||
|
||||
**Markers had to be redrawn from nothing.** MapLibre has no `BitmapDescriptor`
|
||||
equivalent. `map_marker_icons.dart` draws teardrop pins, rider dots, and numbered
|
||||
step circles directly onto a `dart:ui` Canvas and rasterises them to PNG bytes,
|
||||
cached by a composite key so identical icons register once.
|
||||
|
||||
**Diffing instead of clear-and-re-add.** On the navigation screen the rider marker
|
||||
moves every few metres. Tearing down and re-adding every annotation would flicker
|
||||
visibly. `_NearleMapState` keeps `Map<String, Symbol>` / `Map<String, Line>`
|
||||
registries and calls `updateSymbol`/`updateLine` on changed items only. Two
|
||||
supporting details make this work:
|
||||
|
||||
- `NearleMapMarker`'s `==` **deliberately excludes `onTap`**. A new closure on
|
||||
every rebuild would otherwise mark every marker dirty. Taps are instead resolved
|
||||
by looking up the ID against the *current* widget's marker list, so the handler
|
||||
still closes over fresh state.
|
||||
- Native annotation calls aren't re-entrant, so `_syncing`/`_syncQueued` flags
|
||||
coalesce overlapping syncs into one trailing run.
|
||||
|
||||
**Degenerate camera bounds.** Fitting the camera to a single point (or two points
|
||||
on an exact horizontal line) makes MapLibre's native fit routine fail. `fitBounds`
|
||||
nudges any span below `0.0015°` (~165 m) outward around its midpoint first.
|
||||
|
||||
### Trade-offs accepted
|
||||
|
||||
- **No info windows.** MapLibre has no equivalent. Marker taps still fire and
|
||||
drive the customer sheet; tap-to-show-title bubbles are gone.
|
||||
- **No traffic layer, no 3D buildings.** No MapLibre equivalent, and OSRM has no
|
||||
live-traffic model either — ETAs are free-flow estimates.
|
||||
- **The public FOSSGIS OSRM server is the default.** It's donation-funded with a
|
||||
fair-use policy a delivery fleet would abuse. `deploy/osrm/` ships a Docker
|
||||
Compose stack and preprocessing guide for self-hosting; the whole southern-India
|
||||
extract is only 0.52 GB. The endpoint is overridable via
|
||||
`--dart-define=OSRM_BASE_URL=…` with no code change.
|
||||
|
||||
### Verification
|
||||
|
||||
The migration was checked at the **artifact** level, not the build-log level — the
|
||||
release AAB was unzipped and inspected: `libmaplibre.so` present for 3 ABIs,
|
||||
`com/google/android/gms/maps` references **0**, `org/maplibre` references 341.
|
||||
`test/maps_smoke_test.dart` runs 8 tests including live hits against both
|
||||
endpoints, validating the polyline decoder against Google's canonical published
|
||||
example and asserting no `api_key=` appears in the fetched style document.
|
||||
|
||||
---
|
||||
|
||||
## 3. Delivery Lifecycle & Compensation Engine
|
||||
|
||||
### What it is
|
||||
|
||||
The state machine a delivery moves through, and the money that falls out of it.
|
||||
|
||||
```
|
||||
accepted → active → arrived → picked → delivered
|
||||
│ │ │
|
||||
├─ rejected └─ (or skipped / cancelled)
|
||||
```
|
||||
|
||||
Each transition is a method on `DeliveriesController` and each does far more than
|
||||
flip a status.
|
||||
|
||||
### The delivery-confirmation path
|
||||
|
||||
`updateDeliveredStatus()` is ~400 lines and reads like a checklist of everything
|
||||
that can go wrong in the field:
|
||||
|
||||
**Step 1 — Geofence gate.** Compute Haversine distance from the rider to the drop
|
||||
point. If it exceeds a server-configured radius, show the exact distance and
|
||||
required radius, and **refuse the update**. This is the anti-fraud gate that stops
|
||||
"delivered" being tapped from home.
|
||||
|
||||
**Step 2 — Distance resolution, four-tier cascade.** This is a
|
||||
graceful-degradation ladder, best source first:
|
||||
|
||||
1. Cumulative GPS odometer (§1) — the actual path ridden
|
||||
2. Caller-supplied `actualKms`
|
||||
3. OSRM road distance from a recorded origin — tried against the saved start
|
||||
location, then the provided pickup, then the cached pickup, then the drop point
|
||||
4. Straight-line Haversine — if routing is unreachable
|
||||
|
||||
Then a floor: if everything failed, use 0.01 km. Never zero, never null. The
|
||||
reasoning is visible in the code — a null propagating into `ridercharges` means an
|
||||
unpaid delivery and an angry rider, which is worse than a slightly wrong number.
|
||||
|
||||
**Step 3 — Bonus points.** An ETA deadline is stamped at `eta_endtime_{orderId}`
|
||||
when the delivery goes active. Delivered at or before it → bonus points equal to
|
||||
`round(distance_km)`.
|
||||
|
||||
**Step 4 — Skip penalty.** A 3-hour rolling window tracks skip count. If the rider
|
||||
is over the limit, bonus points are **forfeited to zero** — the base per-km pay is
|
||||
untouched. Penalising the incentive rather than the wage is a deliberate, and
|
||||
fairly humane, design choice.
|
||||
|
||||
**Step 5 — Coordinate validation.** Reject `0`, reject `|lat| > 90`, reject
|
||||
`|lng| > 180`, and abort the whole update rather than post garbage. Compare with
|
||||
the geofence check, which *fails open* on missing coordinates (warn and proceed) —
|
||||
the two policies are opposite, and correctly so. A geofence failure shouldn't
|
||||
strand a rider who genuinely delivered; a malformed coordinate write would corrupt
|
||||
the record permanently.
|
||||
|
||||
**Step 6 — Money.** `ridercharges = km × fuelcharge`, rounded to 2 decimals to
|
||||
match a `DECIMAL(10,2)` column server-side.
|
||||
|
||||
### Route sequencing
|
||||
|
||||
Stop ordering (`step`) is assigned **server-side** — the app doesn't solve a
|
||||
travelling-salesman problem. What it does handle is *display* sequencing:
|
||||
`preservedStepNumbers` keeps numbering stable as deliveries complete or get
|
||||
skipped, so a stop the rider has been calling "number 3" doesn't silently renumber
|
||||
itself mid-shift. Orders arriving without a step get synthesised display numbers
|
||||
appended after the numbered ones. Small feature, real usability value.
|
||||
|
||||
---
|
||||
|
||||
## 4. Background Execution & Shift Automation
|
||||
|
||||
### What it is
|
||||
|
||||
Android aggressively kills background work — Doze mode, app standby, and
|
||||
OEM-specific battery managers (particularly aggressive on the Chinese-brand
|
||||
handsets common in this market). But a rider's shift must be logged, and their
|
||||
shift must end on time, whether or not the app is alive.
|
||||
|
||||
This system is a **layered survival strategy**: each layer covers the failure of
|
||||
the one above.
|
||||
|
||||
```
|
||||
Layer 1 — App foregrounded
|
||||
LiveTrackingService, 3 s cadence, full fidelity
|
||||
|
||||
Layer 2 — App backgrounded
|
||||
flutter_foreground_task, 30 s repeat, persistent notification,
|
||||
foregroundServiceType="dataSync|location", wake + WiFi locks
|
||||
|
||||
Layer 3 — App process killed
|
||||
Android AlarmManager, setExactAndAllowWhileIdle (pierces Doze)
|
||||
→ ShiftEndReceiver.kt, pure Kotlin, no Flutter engine
|
||||
→ reads Flutter's SharedPreferences directly ("flutter." prefix)
|
||||
→ raw HttpURLConnection POST, under a 60 s PARTIAL_WAKE_LOCK
|
||||
|
||||
Layer 4 — Phone rebooted / alarm lost
|
||||
On next app launch, main.dart replays the shift-end check
|
||||
and creates the break log retroactively
|
||||
```
|
||||
|
||||
Layer 3 is the interesting one. The receiver deliberately **does not** boot the
|
||||
Flutter engine — starting a Dart VM from a broadcast receiver is slow and
|
||||
unreliable. Instead it reaches into `FlutterSharedPreferences` (Flutter prefixes
|
||||
every key with `flutter.`, which the Kotlin reads verbatim), hand-builds the JSON
|
||||
payload as a string, and POSTs it with `HttpURLConnection`. The break log is
|
||||
created correctly even from a cold-killed app.
|
||||
|
||||
### Overnight shift arithmetic
|
||||
|
||||
Shifts are stored as bare `HH:mm:ss` strings with no date, which makes "has the
|
||||
shift ended?" genuinely ambiguous. The logic compares start and end as fractional
|
||||
hours; if `start > end`, the shift crosses midnight, and "over" means *within the
|
||||
gap between end and start*, not simply "after end":
|
||||
|
||||
```
|
||||
22:00 start, 06:00 end
|
||||
now 23:00 → after start → ACTIVE
|
||||
now 05:00 → before end → ACTIVE (yesterday's shift)
|
||||
now 10:00 → after end AND before start → OVER
|
||||
```
|
||||
|
||||
That check is implemented three times — Dart foreground, Dart background isolate,
|
||||
and Kotlin — because each layer may be the only one running. Triplicated logic is a
|
||||
maintenance liability, and it's the right call anyway: a single shared
|
||||
implementation isn't reachable from a receiver that can't start Dart.
|
||||
|
||||
---
|
||||
|
||||
## 5. Offline Resilience
|
||||
|
||||
Riders lose signal in basements, lifts, and rural stretches constantly. Every
|
||||
write path has a queue behind it.
|
||||
|
||||
```
|
||||
POST attempt (8–10 s timeout)
|
||||
│
|
||||
success ──► persist "last known good" anchor, done
|
||||
│
|
||||
failure ──► append {orderId, payload, timestamp} to a
|
||||
SharedPreferences string-list queue
|
||||
│
|
||||
▼
|
||||
on next successful cycle / app start / reconnect:
|
||||
drain queue, retry each, keep failures, rewrite remainder
|
||||
```
|
||||
|
||||
Two independent queues exist — `offline_delivery_logs` and `offline_rider_logs` —
|
||||
each with an `_isFlushing` re-entrancy guard so overlapping timers can't
|
||||
double-post. Requests carry explicit timeouts throughout, so a hung socket
|
||||
degrades to a queued write instead of freezing a UI thread.
|
||||
|
||||
Payloads are persisted per order (`delivery_payload_{orderId}`) so a delivery in
|
||||
progress survives a process kill and resumes with the same identity rather than
|
||||
starting a fresh log.
|
||||
|
||||
---
|
||||
|
||||
## 6. Real-Time Telemetry Bus (MQTT)
|
||||
|
||||
Location, status, and device health stream to dispatch over MQTT rather than HTTP
|
||||
polling.
|
||||
|
||||
```
|
||||
nearle/riders/{riderId}/status retained + Last Will (QoS 1)
|
||||
nearle/riders/{riderId}/location QoS 0, ~every 3 s
|
||||
nearle/riders/{riderId}/profile retained (QoS 1)
|
||||
nearle/riders/{riderId}/telemetry every 5 min (QoS 1)
|
||||
nearle/riders/{riderId}/logs/{event} (QoS 1)
|
||||
nearle/riders/{riderId}/alerts (QoS 1)
|
||||
```
|
||||
|
||||
The QoS choices are thought through, not defaults. Location is **QoS 0** — a
|
||||
dropped fix is replaced by a fresher one 3 seconds later, so guaranteed delivery
|
||||
would be pure overhead. Status is **QoS 1 and retained**, so a dispatcher opening
|
||||
the console sees current state immediately rather than waiting for the next
|
||||
publish.
|
||||
|
||||
**Last Will and Testament** is the sharp detail: the client registers an `Offline`
|
||||
message with the broker at connect time. If the rider's phone dies, loses signal,
|
||||
or force-quits, the *broker* publishes that message on their behalf. Dispatch
|
||||
learns the rider is gone without any cooperation from the dead client.
|
||||
|
||||
Client IDs are salted per isolate startup (`rider_{id}_{base36 timestamp}`) —
|
||||
otherwise the main and background isolates would connect with identical IDs and
|
||||
MQTT brokers evict the older session, causing the two to kick each other offline in
|
||||
a loop.
|
||||
|
||||
Alerts fire on device conditions dispatch actually cares about: location services
|
||||
disabled, battery under 15% and not charging, GPS accuracy worse than 30 m.
|
||||
|
||||
---
|
||||
|
||||
## Supporting Features
|
||||
|
||||
Smaller but real:
|
||||
|
||||
- **Auth** — phone precheck → OTP (with `sms_autofill`) → MPIN, with server-side
|
||||
PIN verification and session refresh
|
||||
- **Picture-in-Picture** — native Android PiP via `MethodChannel`, so an ETA
|
||||
countdown stays visible while the rider is in Google Maps
|
||||
- **Proximity alerts** — within 50 m of the drop, fire a local notification plus a
|
||||
bundled audio clip, falling back to TTS if playback fails. A per-delivery flag
|
||||
ensures once only
|
||||
- **Proof-of-delivery upload** — camera capture → DigitalOcean Spaces
|
||||
(S3-compatible, via the `minio` client) → CDN URL returned to the API
|
||||
- **Push** — FCM with per-rider selectable alert sounds; the Android notification
|
||||
channel is *recreated* under a new ID when the sound changes, because channel
|
||||
settings are immutable after creation
|
||||
- **Summary & rewards** — `fl_chart` dashboards, weekly km, scratch-card rewards
|
||||
(`scratcher` + `confetti`)
|
||||
- **Support tickets**, FAQ, saved addresses, forced-update gate against the Play
|
||||
Store version
|
||||
|
||||
---
|
||||
|
||||
## The Stack
|
||||
|
||||
**Language / runtime**
|
||||
- Dart (SDK ^3.9.2) · Flutter · Kotlin (native Android layer)
|
||||
|
||||
**State & architecture**
|
||||
- GetX (`get`) — DI, reactive state, routing · Provider · MVC-ish split:
|
||||
`views/` → `controllers/` → `providers/` → `Models/`
|
||||
|
||||
**Geo**
|
||||
- `maplibre_gl` (rendering) · OpenFreeMap (OSM vector tiles) · OSRM (routing) ·
|
||||
`geolocator` · `geocoding` · custom Kalman filter · custom polyline codec
|
||||
|
||||
**Background & lifecycle**
|
||||
- `flutter_foreground_task` · Android AlarmManager (native) · `wakelock_plus` ·
|
||||
`battery_plus` · `connectivity_plus`
|
||||
|
||||
**Networking & messaging**
|
||||
- `http` · `mqtt_client` · Firebase Core + Messaging (FCM) · `minio`
|
||||
(S3-compatible object storage)
|
||||
|
||||
**Storage**
|
||||
- `shared_preferences` (state, queues, cross-isolate coordination) ·
|
||||
`path_provider` · DigitalOcean Spaces + CDN (media)
|
||||
|
||||
**UI**
|
||||
- `flutter_screenutil` · `fl_chart` · `lottie` · `shimmer` · `confetti` ·
|
||||
`scratcher` · `flutter_slidable` · `webview_flutter` · Proxima Nova
|
||||
|
||||
**Device**
|
||||
- `permission_handler` · `image_picker` · `flutter_tts` · `audioplayers` ·
|
||||
`vibration` · `sms_autofill` · `url_launcher` · `floating` (PiP)
|
||||
|
||||
**Build**
|
||||
- Gradle (Groovy) · compileSdk 36 · NDK 28.1.13356709 · R8 minify + resource
|
||||
shrink · arm64-v8a only · custom release-artifact naming task
|
||||
|
||||
**Backend (consumed, not in this repo)**
|
||||
- REST across two hosts — a primary API and a separate write/queue host · MQTT
|
||||
broker on a VPS · versioned endpoints (v1/v2/v3) · `dev`/`live` switched by one
|
||||
constant
|
||||
|
||||
---
|
||||
|
||||
## What's Genuinely Hard Here
|
||||
|
||||
Ranked by how much real thought each needed.
|
||||
|
||||
**1. Cross-isolate odometer coordination.** Two Dart isolates with no shared
|
||||
memory, both holding a GPS stream, both able to increment the same paid counter,
|
||||
either able to be frozen by the OS without notice. The heartbeat-lease over
|
||||
`SharedPreferences` solves it in about 15 lines — but arriving at that solution
|
||||
means understanding Flutter's isolate model, Android's process lifecycle, and why
|
||||
the obvious fixes (a flag, a mutex, a port) all fail across a process boundary.
|
||||
|
||||
**2. Trustworthy distance from an untrustworthy sensor.** Not "apply a Kalman
|
||||
filter" — the filter is the easy half. The hard half is the *asymmetry*: discard
|
||||
distance but move the anchor on a jump; discard the sample but hold the anchor on
|
||||
jitter. Both directions have to be right or the odometer drifts in one direction,
|
||||
and that direction is money. The velocity-scaled jump threshold, so the same guard
|
||||
works at 3 s and 60 s cadences, is the same kind of thinking.
|
||||
|
||||
**3. Correctly diagnosing the Maps bill.** The valuable engineering was the
|
||||
analysis, not the code. "Maps SDK is free; Directions is billed; the multi-stop
|
||||
screen was making N calls per screen-open; the key couldn't be restricted because
|
||||
the calls were web-service calls" — that chain of reasoning is what turned an
|
||||
unbounded vendor migration into a well-scoped one that landed. Plenty of teams
|
||||
would have swapped rendering engines and wondered why the bill barely moved.
|
||||
|
||||
**4. Four-layer background survival.** Anyone can start a foreground service.
|
||||
Making shift-end fire from a *killed process* means: knowing
|
||||
`setExactAndAllowWhileIdle` is the only AlarmManager call that pierces Doze;
|
||||
knowing a broadcast receiver shouldn't boot a Flutter engine; knowing Flutter's
|
||||
`SharedPreferences` keys carry a `flutter.` prefix readable from Kotlin; and
|
||||
knowing you need a `PARTIAL_WAKE_LOCK` or the device sleeps mid-HTTP-request. Each
|
||||
of those is a scar.
|
||||
|
||||
**5. Flicker-free declarative annotations over an imperative native map.**
|
||||
MapLibre's controller is imperative — `addSymbol`, `updateSymbol`, `removeSymbol`
|
||||
— and non-re-entrant. Wrapping that in a declarative Flutter widget requires a
|
||||
keyed diff, sync coalescing, and an equality contract that excludes callbacks. The
|
||||
`onTap`-excluded `==` is a subtle, correct call that most implementations get wrong
|
||||
and then blame on "MapLibre being flickery."
|
||||
|
||||
**6. Overnight shift arithmetic.** Deceptively small. Bare `HH:mm:ss` with no date
|
||||
makes "is the shift over?" ambiguous, and the answer inverts for shifts crossing
|
||||
midnight. Getting it wrong means riders auto-clocked-out at 10 PM mid-delivery, or
|
||||
never clocked out at all.
|
||||
|
||||
**7. Graceful degradation as a design stance.** The four-tier distance cascade,
|
||||
the four-tier start-time fallback, the fail-open geofence next to the fail-closed
|
||||
coordinate validator — these aren't defensive-programming reflexes. Someone
|
||||
decided, per code path, *which* wrong answer is least harmful, and the policies
|
||||
differ where they should. That's the mark of software written by someone who had to
|
||||
answer for its failures in the field.
|
||||
|
||||
---
|
||||
|
||||
## Security & Sensitivity Flags
|
||||
|
||||
**⚠️ Credentials in source — do not include any of this in a portfolio, and rotate
|
||||
before publishing the repo.** All read directly, not inferred:
|
||||
|
||||
| Location | Issue |
|
||||
| --- | --- |
|
||||
| `lib/controllers/deliveries_controller.dart:75-76` | DigitalOcean Spaces **access key + secret key** hardcoded. These ship inside the APK and are recoverable with `strings`. They grant object-storage writes. |
|
||||
| `lib/views/helpers/constants/mqtt_constants.dart` | Broker **IP, username, and password** hardcoded. Port 1883 is **unencrypted MQTT** — credentials and every rider's live location cross the network in plaintext. |
|
||||
| `lib/helpers/http_overrides.dart` | `badCertificateCallback` returns `true` unconditionally, installed globally in `main()` **and** in the background isolate. This disables TLS certificate validation app-wide, making every API call MITM-able. |
|
||||
| Repo root & `android/` | `nearlerider-keystore.jks`, `release-key.jks`, `android/key.properties`, `upload_certificate.pem` are **tracked in git**. The signing keystore plus its password file is the set that lets someone publish as you. |
|
||||
| git history | The previous Google Maps API key remains in history and in every already-shipped APK. It needs revoking in Google Cloud Console regardless of the migration. |
|
||||
|
||||
**Commercially sensitive — describe generically, don't quote:**
|
||||
|
||||
- The compensation formula and its rate key, bonus-point rules, and the 3-hour
|
||||
skip-penalty window
|
||||
- Geofence radius policy
|
||||
- Backend host topology and endpoint paths (two distinct hosts, versioned APIs)
|
||||
- MQTT topic schema and broker address
|
||||
|
||||
**Inference vs. reading.** Everything above is read from source except: the backend
|
||||
implementation (only its HTTP surface is visible from here), the server-side `step`
|
||||
assignment algorithm (the app consumes it, doesn't compute it), and the actual
|
||||
production billing figures for the old Google setup (the pricing model is
|
||||
documented, the invoice isn't in the repo).
|
||||
|
||||
**Also worth knowing for accuracy:** the app is Android-only in practice —
|
||||
`abiFilters "arm64-v8a"`, and the iOS directory has no Podfile. The root
|
||||
`README.md` is still the unmodified Flutter template.
|
||||
Reference in New Issue
Block a user