-
-
Notifications
You must be signed in to change notification settings - Fork 0
Development Walkthrough
Sertel Şekerci edited this page Aug 9, 2026
·
2 revisions
A current-state summary of every major subsystem in the app, organized by area rather than by
when it was built. Kept up to date as features change — see docs/implementation_plan.md for the
architectural patterns behind these.
- Connects to a Bluetooth Classic (RFCOMM) ELM327-compatible adapter, remembers the last device
picked from the Panel's OBD badge dropdown, and falls back to auto-detecting a paired device by
name hint (
OBD,ELM327,VLINK,ICAR,VGATE,OBDLINK, etc.) if none is remembered. - A 100ms data loop (
pollOnce()) requests the hot, gauge-driving signals every tick: RPM, Speed, Gear, Throttle, front/rear Brake, Lean angle (bike IMU), Coolant temp. - A slower tier (
pollSlowPids(), same ~5s cadence as DTC polling) covers PIDs that don't need 100ms freshness: odometer, distance since DTC clear, fuel rate/level, battery voltage, intake/ ambient temp, engine load, MAP pressure, timing advance, engine runtime, distance with MIL on, catalyst temp (both banks), fuel trims, O2 sensor voltage/trim, absolute load, equivalence ratio, throttle positions, pedal positions, commanded throttle actuator. Results from both tiers are merged (not replaced) into the sameMap<String, Int>so slow-tier values persist between fast refreshes. -
Confirmed sensor map: every request this manager knows how to make lives in one place,
BluetoothOBDManager.CONFIRMED_PID_MAP— a(header, command, signal name)triple. Every parse function reports whether its last response actually matched what was expected viamarkPidStatus(), exposed aspidStatus: StateFlow<Map<String, Boolean>>. The Bike Info screen renders this live, so the "confirmed" table reflects what's actually answering right now, not a static claim. -
Dead-link detection:
sendCommand()/readResponse()intentionally swallowIOExceptioninternally so one unsupported PID doesn't kill the polling loop — but that also means a genuinely dead socket (adapter losing power when the ignition switches off) never used to surface. AconsecutiveWriteFailurescounter now tripsdisconnect()after 8 failures in a row (roughly onepollOnce()cycle), since a write failure (unlike a read timeout) only happens when the RFCOMM link itself is broken. -
Serial-read discipline:
readResponse()enforces its 2s timeout by pollingInputStream.available()with a shortdelay()between polls — a blockingread()can not be timeboxed bywithTimeoutOrNull(cancellation is only observed once the read returns on its own), which used to mean a genuinely silent link hung the poll loop until a byte arrived.sendCommand()also drains any bytes still buffered from a previously timed-out command before writing, so a late-arriving response can't be attributed to the wrong request. The CAN monitor's capture loop uses the sameavailable()-based pattern for its deadline. Connections are serialized through a dedicatedconnectMutex(our own fresh RFCOMM connect raisesACL_CONNECTED, whose receiver used to fire a second, racingconnect()), andinitELM327()verifies theATZbanner actually saysELM327before declaring the link up — a dead or foreign SPP device no longer gets a "Connected" badge over a poll loop full of zeros. -
Check-engine diagnostics: polls Mode 01 PID 01 (MIL status + DTC count) every ~5s, fetches
Mode 03 (stored codes) only when there's something to fetch, decodes per SAE J2012 (skipping the
leading DTC-count byte the CAN response carries, and reassembling ISO-TP multi-frame replies for
3+ codes), and supports clearing via Mode 04 (
clearDtcs()), gated behind a confirmation dialog since it resets ECU readiness monitors. -
Diagnostic sweep tools, all serialized through one
Mutexso they can't interleave with the regular polling loop on the same serial socket:-
sweepStandardPidSupport()— safe, standard Mode 01 PID 00/20/40… bitmask discovery. - The original DID sweep — probes manufacturer UDS DIDs across
DEFAULT_SWEEP_HEADERS(7E0engine ECU,7E1ABS/IMU only —7E2-7E7are deliberately excluded since probing unconfirmed headers is suspected of causing a dash/start-stop hiccup on this bike). -
trySecuritySessionProbe()— explicitly risky: sends10 03(UDS Extended Diagnostic Session) then re-probes the two still-unmapped brake DIDs (43FE/43FF), always returns to Default Session (10 01) before finishing. Never auto-invoked; gated behind a UI confirmation dialog telling the rider to only run it with the engine off, since unlike every other query in this app it changes ECU session state rather than just reading. -
startCanMonitor(durationSeconds)— passive, read-only raw CAN bus capture (ATH1+ATMA). Reads are wrapped in a timeout so a quiet bus (e.g. ignition-on/engine-off) can't hang the socket indefinitely; auto-restartsATMAonBUFFER FULLuntil the requested duration elapses; throttles UI updates to every 20 frames plus a final flush so multi-minute captures don't thrash the StateFlow. Used to reverse-engineer signals that aren't exposed via request/response at all (e.g. lean angle, which the IMU appears to broadcast on the bus rather than answer on request).
-
-
TelemetryService(foreground service) owns the actual ride-recording lifecycle: GPS, IMU (lean/G-force via phone sensors, with a one-tap calibration to zero out mount tilt), and the OBD data loop, all sampled and written to Room at 5Hz regardless of whether OBD is connected. - Binding (
bindService,BIND_AUTO_CREATE) only runs the service'sonCreate()— it does not start recording. Recording only begins via an explicitstartService()→onStartCommand(), guarded by atrackingStartedflag so a flaky Bluetooth reconnect while already tracking can't spin up a second overlapping session. -
Auto-start & ride continuation:
ObdAutoStartReceiver(manifest-registered, works even with the app process dead, since ACL-connect broadcasts are exempt from background-broadcast restrictions) starts the service when the remembered OBD device connects, if the setting is on.TelemetryServicealso registers a dynamic receiver forACL_CONNECTED/ACL_DISCONNECTEDon that device: on disconnect it drops the OBD link and starts a grace-period timer (configurable in Settings, default 10 min); reconnecting before it expires resumes into the same session, otherwise the ride is finalized. Finalization on grace timeout happens explicitly (finalizeSession()+stopForeground+stopSelf), not by relying ononDestroy()— a started-and-bound service isn't destroyed bystopSelf()while the UI still holds its binding, which used to leave the ride recording phone-sensor records until the user happened to leave the app.onDestroy()keeps a no-op-if-already-finalized fallback for the explicit Stop path. -
Ride aggregates: fuel consumption integrates the rate over the measured interval between
loop iterations (capped at 5s so a stall can't over-count), not a hard-coded 0.2s; the ride's
startOdometeris captured from the first non-zero ODOMETER reading of the ride (reading it at connect time always got 0, which would have turnedendOdometer - startOdometerinto the bike's absolute odometer the day that DID starts answering);startOdometer/endOdometerandavgFuelConsumption(L/100km over GPS distance) are now written onto the finalized session. - Room DB uses
fallbackToDestructiveMigration()(dev-only) with a safety net that snapshots the raw DB file before a version-bump wipe.
- Bottom nav and the Home screen's Quick Access grid share the same tab order: Panel → History → Analysis → Bike Info → Settings.
-
Home: last-ride card, fuel level + estimated range, a service-interval countdown, today's
ride stats, and Start Tracking/Stop/Backup/Restore controls. The OBD status badge lists paired
Bluetooth devices directly via
getPairedBluetoothDeviceEntries()— a plainBluetoothManager.adapter.bondedDevicesread that needs no service binding, so opening the device picker from Home can never accidentally trigger service creation or a navigation jump. - Panel: Speed/Gear/RPM/Throttle/Brake gauges, the lean gauge (phone or bike source, tap to calibrate), and the check-engine badge.
- History: recorded routes as polylines on a Google Map, with distance/duration/average-speed overlay.
- Analysis: session list (sparkline, stats, rename, delete) plus a detail view with four Vico charts — Speed & RPM, Lean Angle, Lateral/Longitudinal G-Force, and Altitude.
-
Bike Info: identity card + live odometer, an engine-health stat grid, a permanent diagnostics
card, the live sensor-status table (
PidMapTable, one row perCONFIRMED_PID_MAPentry with a green/red/gray status dot), the standard-PID sweep, the manufacturer DID sweep, the risky extended-session probe (warm-colored, its own confirmation dialog), and the CAN monitor (duration picker, live frame count, share-to-text export). - Settings: auto-start toggle, grace-period stepper, battery-optimization exemption status, diagnostic log sharing, language picker.
-
GoogleDriveManagerbuilds the Drive client directly from the OAuth access token returned by the modern Credential Manager / Authorization API sign-in flow (no legacyGoogleAccountCredential). - Backups checkpoint the Room WAL file before copying the database, so a backup taken right after a
ride doesn't miss writes still sitting in
-wal. - Restore lists backups from the Drive
appDataFolder, lets the user pick one plus a Replace/Merge choice, downloads it, and imports it via raw SQLite reads (data/BackupRestore.kt) — Merge skips sessions whosestartTimealready exists locally, Replace wipes local data first. Only identity and time columns are read strictly; every other column falls back to a default when absent, so a backup taken on an older schema restores with degraded fields instead of failing outright. - Requires a Web-application OAuth client (not the Android client) as the
setServerClientId(...)token audience, and every signing-in account added under Audience → Test users while the consent screen is in Testing status — seeREADME.mdsetup steps.
- UI/navigation-affecting changes are verified via
adb(cold launch, screenshot, exercise the interaction path) before being reported as done, not just built and unit-tested — a real regression (Home unexpectedly jumping straight to Panel) shipped once from binding side effects that weren't caught until a screenshot was taken. -
./gradlew testDebugUnitTestcovers parsing logic (e.g.testGearParsing) that's easy to break silently — a logging call added insideparseGear()'s success branch once corrupted the returned gear value becauseLog.wthrows in the unmocked JVM test environment and the outer catch-all converted that into a wrong return value.