621 lines
30 KiB
Markdown
621 lines
30 KiB
Markdown
# 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.
|