Android telematics SDK (take-home for Miracle Traffic AI). A production-minded trip recorder: explicit state machine, Room as source of truth, WorkManager sync, durable idempotency keys, and a small Compose demo.
This is not a toy GPS logger. The interesting parts are when a trip exists, what survives process death, and how a batch is counted once.
TripProbe arms a foreground service, watches Activity Recognition + fused location, and records location/accelerometer probes into Room. When a batch is sealed, WorkManager uploads it to a local MockWebServer with Idempotency-Key: <batchId>. Airplane mode does not stop recording. Local batches are deleted only after a successful acknowledgement.
Evaluation focus: architecture, background reliability, offline-first sync, exactly-once batch processing, consent/privacy, tests, and explainability. UI is intentionally thin.
Demo (Compose, consent, simulation)
│ TripProbe public API
▼
┌───────────────────────────────────────────┐
│ tripprobe-sdk │
│ detection/ TripStateMachine (pure JVM) │
│ recording/ TripRecorder │
│ service/ TripProbeForegroundService │
│ persistence/ Room (source of truth) │
│ sync/ BatchManager + SyncEngine │
│ WorkManager upload │
└───────────────────────────────────────────┘
The SDK does not depend on Compose. The demo owns navigation, theme, consent copy, and mock-location buttons.
Recording and synchronization are separate:
Foreground Service → Room → sealed batch → WorkManager → POST /v1/probe-batches
tripprobe-sdk/ Library: API, domain, detection, recording, persistence, sync, FGS
tripprobe-demo/ Application: consent, Compose screens, MockWebServer
docs/adr/ Three architecture decision records
Package namespace: com.tripprobe.app (demo), com.tripprobe.* (SDK).
See ADR-001.
States: IDLE → WARMING_UP → RECORDING ⇄ PAUSED → ENDING → IDLE.
- Vehicle activity arms warm-up.
- Recording requires accurate GPS and movement.
speed == 0is not a trip end. - Traffic lights stay in
RECORDINGuntilstationaryTimeoutMillis(default 3 minutes). - GPS gaps enter
PAUSEDand recover; they do not complete the trip. - The state machine takes timestamps from events. It never calls
System.currentTimeMillis()and never touches Android APIs, so it runs in JVM unit tests.
TripProbeForegroundService uses foregroundServiceType="location". It owns location, activity, and accelerometer subscriptions and writes to Room. It does not upload.
WorkManager uploads when the network is available. Doze can delay workers. A foreground service is not a guarantee against OEM process killing (see Thinking Ahead).
After reboot the SDK does not auto-start; the partner app should call start() again if the product requires it.
Room tables: trips, location_probes, accelerometer_probes, probe_batches.
Every probe and every batch has a durable unique ID. Probes begin with batchId = null (collecting). Sealing assigns a batch ID in a transaction-like update. Derived driving events (harsh braking, scoring) are not stored here so they can be added later without redesigning raw probes.
See ADR-002.
| Client event | Local effect |
|---|---|
| Probe recorded | Insert row, batchId = null |
| Size/age/trip end | Insert SEALED batch, attach probes |
| Upload 2xx | Mark ACKED, then delete batch + probes |
| 408 / 429 / 5xx / I/O | RETRY_PENDING + backoff |
| Other 4xx | PERMANENTLY_FAILED (not deleted; inspectable) |
Never delete a sealed batch before acknowledgement. That is the offline-first invariant.
ProbeBatch.batchId is generated at seal time, persisted, and sent as:
Idempotency-Key: <batchId>Retries reuse that exact string. The mock server keeps a processed-id set. A duplicate POST returns success and does not increment the processed counter.
This is exactly-once processing, not exactly-once delivery. Delivery can happen twice; processing is keyed.
Recording cannot start until:
- Explicit consent in the demo (purpose-linked copy, not a blank permission chain).
- Fine location, activity recognition (API 29+), notifications (API 33+).
- Background location, requested on its own screen with an explanation (Android’s two-step policy).
The SDK start() returns a failure Result if consent or permissions are missing.
truncateCoordinate(value, precision) runs in the upload path (default 5 decimal places, ~1.1 m). The UI never does this. Local Room still stores full fixes so distance and detection stay accurate; the wire format is minimised.
deleteAllTrips() removes trips, probes, and pending batches in a Room transaction.
This is not legal advice. For a product in India, Digital Personal Data Protection Act (DPDPA) expectations would include:
- Consent that is free, specific, informed, and purpose-linked. This demo gates recording on an explicit agree step and does not start the FGS before that.
- Notice of what is collected, why, and when. The consent screen states this in plain language.
- Minimisation of personal data on upload (truncated coordinates, no contacts, no raw microphone).
- User rights: local deletion via Delete My Trips / consent withdrawal.
- What this demo is not: a production privacy program. A real platform still needs retention schedules, security controls, processor contracts, grievance redressal, and lawful cross-border transfer design. The SDK is a client component; it cannot satisfy organisational DPDPA duties by itself.
./gradlew :tripprobe-sdk:test
./gradlew lintCovered in JVM tests:
- State machine: warm-up, recording, GPS pause/resume, traffic-signal stationarity, prolonged park, ending, event-driven time, restore-after-“process death”.
- Sync: seal, ack-then-delete, retry, same idempotency key, MockWebServer duplicate POST, recreation of
SyncEngineagainst the same store. - Coordinate truncation and backoff caps.
- Open the
tripprobe-demorun configuration (application idcom.tripprobe.app). - Read the consent screen. Grant foreground permissions, then background location, then agree.
- On Home, tap Arm. A recording notification should appear.
- Tap Drive. State should move
WARMING_UP→RECORDINGwithin a few seconds. - Background the app; the notification should remain and probes should keep landing in Room.
- Enable airplane mode; recording should continue and pending batches should stay local.
- Disable airplane mode; WorkManager should upload. Settings shows received vs processed counts.
- Delete My Trips on the Privacy tab clears local data.
Park uses the production 3 min + 1 min stationary heuristic, so trip end is not instant.
Home simulation controls (no Google “mock location” developer setting required):
| Control | Effect |
|---|---|
| Drive | Vehicle activity + 1 Hz moving points |
| Light | ~60 s near-zero speed (still RECORDING) |
| Tunnel | GPS loss → PAUSED, then drive resumes |
| Park | Zero speed until ENDING / IDLE |
Settings can force HTTP 500, clear the error, or reset the mock processed-id set. A 500 followed by a retry is the duplicate-key demo: processed count stays flat while received count increases.
- Foreground services do not survive force-stop, some OEM battery managers, or denied background location.
- Doze and App Standby can delay WorkManager; they should not stop an actively notified FGS, but OEMs differ.
- Activity Recognition is unreliable on some devices; the demo therefore injects vehicle activity.
- The mock server is in-memory; process death of the demo app resets processed IDs (Room batches still retry).
- No map/polyline. No driver scoring. No production backend, auth, or TLS pinning.
- Reboot does not auto-arm the SDK.
- Exact-once requires the server to persist keys. The mock does this only in RAM.
This repository was implemented with Cursor Agent (Grok 4.6) from the TripProbe master prompt, then iterated by compiling, testing, and fixing. Architecture, invariants, and ADRs follow the assignment; they were not copied from a previous candidate submission. A human reviewer should still treat this as an interview artifact: walk the state machine and the ack-before-delete path, not the line count.
A naive “upload as soon as online” thundering herd will melt the ingest API at commute start.
- Client jitter: already present in backoff. Production should also delay the first attempt by a random window (e.g. 0–120 s) after trip end.
- Batching: larger batches and gzip (not shipped in v1) cut request count and bytes.
- Randomized sync windows: WorkManager periodic 15 min is a floor; add per-device hash of
deviceIdso not everyone wakes on the same wall clock. - Server load shaping: accept 429 and honour
Retry-After. Idempotency keys make safe retries cheap. - Compression and schemas: length-delimited protobuf or gzip JSON once the contract is stable.
- Telemetry: client should report queue depth, last-ack age, upload failure class, FGS restarts, and permission loss — not raw GPS in health metrics.
- Battery/network: adaptive sampling already reduces stationary radio use; at 100k devices, even 1% extra GPS duty cycle is a support incident.
Xiaomi, Oppo, Vivo, Samsung, and OnePlus commonly add autostart, “battery optimization”, and vendor killers on top of AOSP Doze. FGS + START_STICKY is necessary but not sufficient. A partner-embedded SDK should:
- Detect missing background location / ignored battery exemption and surface that in the host app UI.
- Provide a diagnostics snapshot (OEM, battery restricted, FGS running, last probe age).
- Educate users with OEM-specific settings deep-links where legally and technically possible.
- Never claim the process cannot be killed. After death, Room + WorkManager recover data, not live GPS.
Keep raw probes and derived events as separate tables. Future work: gyroscope + accel for harsh braking / acceleration / cornering, with a confidence and severity on a DrivingEvent entity keyed by tripId and time range. GPS speed remains a weak brake signal in urban canyons; fusion should not overwrite raw samples.
The SDK must not own navigation, Compose, authentication, or theme. Host apps:
- Show their own consent and permission UX (this demo is a reference).
- Call
TripProbe.start()/stop()and collectFlows. - Provide
TripProbeConfig.uploadBaseUrl(today a constructor default; a later release should make this injectable without a rebuild). - Must not expect the SDK to render UI or request permissions itself beyond checking them.
Stable API: TripProbe, TripProbeConfig, Trip, TripState, deleteAllTrips(). Simulation APIs are demo-only and can be hidden behind a debug artifact later.