This is a standalone Android app for replacing Torque as the day-to-day OBD bridge.
It does three things:
- Hosts a local Volt-style dashboard in an Android
WebView. - Connects to a paired ELM327-style OBD2 adapter over Bluetooth Classic SPP.
- Streams basic live OBD telemetry into the dashboard.
The app also includes a demo telemetry mode so the UI can be tested without a scanner.
Beyond live OBD streaming, the app ships these user-facing features:
- Home-screen widget — an at-a-glance vehicle snapshot (SOC, charging/connection
state, and an "updated Xm ago" freshness line). Place it like any Android widget:
long-press an empty area of the home screen → Widgets → find Volt Tracker →
drag it out. Tapping the widget opens the app. The widget runs out-of-process and
reads a compact persisted snapshot, so it shows the last known state even when the
app is closed (see
docs/adr/0007-event-notifications-and-widget-snapshot.md). - Event notifications — optional one-shot alerts for charge complete, a
new diagnostic code appearing on a scan, low SOC, and high pack
temperature (the last two are threshold alerts you configure in Settings). These
post to a dedicated "alerts" notification channel. On Android 13+ they require the
POST_NOTIFICATIONSruntime permission — grant it when prompted (or in Android app settings); without it the alerts are silently skipped, never crash. Toggle each alert and its threshold from the dashboard Settings screen. - Per-trip GPX / CSV export — export any logged drive as a GPX 1.1 track or a CSV
sample log from the Trips/Map view and hand it to any app via the Android share
sheet (e.g. Strava, Garmin, a spreadsheet). These files contain full-precision
GPS coordinates and timestamps in plaintext — see
docs/privacy-data-handling.md. - Maintenance log — record service entries (type, free-text note, optional odometer and date) from the Insights screen. The log is independent of OBD sessions and is preserved when you clear stored OBD data.
- Trip labels — rename any logged drive; the label is keyed to the drive and survives trip re-detection. Clear it by saving an empty name.
- Guided onboarding — a first-run setup walkthrough (pair adapter → grant Bluetooth → grant location → connect or demo) that skips steps already satisfied. Re-open it any time from the Setup guide affordance.
- Charge target SOC — set a daily charge ceiling (e.g. 80%) on the Charge tab; the live-charge ETA targets it, and a one-shot "charge target reached" alert fires once per charge when the SOC crosses it (posted to the same alerts channel as the other event notifications).
- Accessibility theme — an Accessibility section in Settings with a font-scale multiplier (1×/1.25×/1.5×) and a high-contrast, colorblind-safe status-color toggle; animations also honor the OS reduced-motion preference.
- Trip favorites — star any logged drive and filter the Map/Trips list to favorites only. The flag is keyed to the drive and survives trip re-detection, like trip labels.
Production source layout under app/src/main/kotlin/com/volttracker/obdpoc/:
| Layer | Files | What it does | Entry point |
|---|---|---|---|
| UI | MainActivity.kt, VoltBridge.kt, assets/dashboard/* |
Hosts the WebView; bridges TypeScript calls back to the service | MainActivity.onCreate |
| Service | ObdService.kt, ObdNotifications.kt, PermissionGate.kt |
Foreground lifecycle, status broadcasts, runtime permissions | ObdService.onStartCommand |
| Engine | ObdPollingEngine.kt, SessionRecorder.kt, ObdProtocol.kt, ElmConnection.kt, ObdElmDecode.kt, ObdProbes.kt, location/* |
Bluetooth IO, ELM327 init, polling loop, parsing, GPS | ObdPollingEngine.runBluetoothLoop |
| Data | data/* (ObdLocalStore, VoltTrackerDb, ObdStoreReports, ObdStoreTrips, ObdStoreSupport, record DTOs) |
SQLite schema, writes, queries, JSON projections for the dashboard | ObdLocalStore |
Calls flow downward only (UI → Service → Engine → Data). See
docs/mobile-architecture-roadmap.md
for the full rule, and docs/adr/0001-webview-dashboard.md
for why the dashboard is a WebView rather than Compose.
The dashboard index.html is generated by the Gradle generateDashboardHtml
task from app/src/main/dashboard-src/partials/*.html. Edit the partials, never
the generated file.
How the layers above connect at runtime (control path left-to-right, data path branching off the polling engine):
flowchart LR
subgraph UI
MA["MainActivity<br/>(WebView dashboard)"]
VB["VoltBridge<br/>(JS bridge)"]
end
subgraph Service
SVC["ObdService<br/>(foreground service)"]
end
subgraph Engine
ENG["ObdPollingEngine"]
ELM["ElmConnection<br/>(Bluetooth Classic SPP)"]
end
subgraph Data
REC["SessionRecorder"]
DB[("SQLite stores<br/>ObdLocalStore / VoltTrackerDb")]
BK["Backup / merge<br/>DataBackup + DatabaseMerger"]
end
MA <--> VB
VB --> SVC
SVC --> ENG
ENG --> ELM
ENG --> REC
REC --> DB
DB <--> BK
The dashboard TypeScript calls into VoltBridge (the JavaScript interface
injected into the WebView); telemetry and status flow back to the page through
DashboardPublisher, which invokes allowlisted window.VoltTrackerNative.*
entry points. Everything below ObdService keeps running while the app is
backgrounded, which is why it is a foreground service.
These are the committed Playwright visual-regression baselines (rendered at a
phone-like viewport on Linux CI), linked in place from
dashboard-e2e/ — they refresh automatically whenever an
intentional UI change lands:
| Drive tab (typical telemetry) | Charge tab (sessions + detection) | Insights tab (full page) |
|---|---|---|
Solid magenta blocks are Playwright screenshot masks over nondeterministic regions (live charts, Leaflet map tiles) — on a phone those areas show the real chart/map.
Dashboard behavior lives in app/src/main/dashboard-src/js/*.ts (bundled by
esbuild into the gitignored assets/dashboard/js/); each screen's markup is a
partial in app/src/main/dashboard-src/partials/.
| Screen / concern | TypeScript | Partial(s) |
|---|---|---|
| Shared namespace, state, Android bridge ABI, restore progress | core.ts, telemetry.ts |
topbar.html, error-banner.html, restore-progress.html |
| Drive tab (live chips, speed/power/SOC charts) | drive.ts |
drive.html |
| Trips list + Insights stats/scatter | insights-panel.ts |
insights.html |
| Map tab (Leaflet, route scrubber, session list) | map.ts, map-route-utils.ts, map-session-list.ts, scrubber.ts |
map.html |
| Charge history + storage summary + post-session review | storage-status.ts |
charge.html, settings.html |
| Diagnostics: DTC lookup (lazy-loaded chunks) | dtc-lookup.ts, dtc-causes.ts |
diagnostics.html |
| Detailed Signals (enhanced PID discovery) | signals-panel.ts |
(rendered into diagnostics.html) |
| Connection status, tools, troubleshooter modal | connection-status.ts, connection-tools.ts, troubleshooter.ts |
connection-tools.html, troubleshooter.html |
| Button wiring + lifecycle | actions.ts, actions-demo.ts, actions-page-scroll.ts, actions-signals.ts, actions-storage.ts |
— |
| User preferences (loaded first in the bundle) | prefs.ts |
preferences.html |
| In-app dialogs, demo data | app-dialog.ts, demo-data.ts |
app-dialog.html |
Styles are plain CSS in app/src/main/assets/dashboard/css/ (no build step).
Use the pinned toolchain before running the full verification task:
- JDK 21. CI uses Temurin 21.
- Android SDK with platform 37 and build tools installed.
- Node 24. The root
.nvmrcand.node-versionboth pin this. - npm dependencies for the dashboard test/build and Playwright workspaces.
From mobile/android/:
npm --prefix dashboard-tests ci
npm --prefix dashboard-e2e ci
npm --prefix dashboard-e2e exec -- playwright install chromium
./gradlew verifyActiveAppUseful local tasks:
./gradlew generateDashboardHtml # rebuild assets/dashboard/index.html from partials
./gradlew verifyGeneratedDashboardClean # fail if generated HTML is stale
./gradlew dashboardAssetReport # print dashboard asset sizes and bundle-budget headroom
./gradlew verifyPerformance # performance-focused dashboard + Android regression lane
./gradlew verifyStartupPerformanceOptional --no-configuration-cache
./gradlew :app:spotlessApply # format Kotlin/Java and dashboard partials
./gradlew :app:testDebugUnitTest # JVM/Robolectric unit testsFor install/release packaging details, see docs/release.md.
For performance review rules, see docs/performance-contracts.md.
For release history, see the repo-root CHANGELOG.md.
For common field failures, see TROUBLESHOOTING.md.
Domain terms are defined in docs/glossary.md.
Production Android source stays in app/src/main/kotlin; do not add production
Java back under app/src/main/java. Dashboard behavior source stays in
app/src/main/dashboard-src/js/*.ts; do not add source .js files there. The
Gradle verifyNoMigrationSourceStragglers task enforces both rules, and
verifyActiveApp runs that guard with the rest of the local validation suite.
The app now writes two layers of local data:
- Raw field-test JSONL files under app-private
files/obd-logs/. - Structured SQLite data in
volttracker_obd_poc.db.
SQLite tables capture OBD sessions, parsed telemetry samples, status/debug events, and adapter history summaries. The Settings screen shows a database summary so field tests can confirm whether sessions and samples are being saved without pulling files from the phone.
For the data/privacy model, backup contents, and map tile network behavior, see
docs/privacy-data-handling.md.
Live polling is tiered in PidSchedule.kt; every published sample carries
stale-age metadata so slower values can be shown honestly without slowing the
speed/RPM path.
| Lane | Cadence | PIDs |
|---|---|---|
| Hot | Every cycle | 010D speed, 010C RPM, 0149 accelerator pedal, 222414 HV pack current |
| Warm | Every 2 cycles | 0104 engine load, 0111 ICE throttle, 222429 HV pack voltage |
| Slow | Every 6 cycles | 015B hybrid battery SOC, ATRV adapter voltage |
| Thermal | Every 12 cycles | 0105 coolant temp, 22434F HV battery temp |
| Deep | 24+ cycles | control-module voltage, run time, fuel/oil/odometer context, motor currents/voltages, PRNDL, charger HV voltage/current, charging mode/level, raw SOC, charge count, last-charge energy, and other validated context |
Diagnostic Scan and Detail Probe still exist for broader discovery, including candidate TPMS probes. Do not promote new community Mode-22 formulas into live polling until they have fresh field evidence from the target car.
From this directory:
# macOS / Linux
./gradlew :app:assembleDebug# Windows
.\gradlew.bat :app:assembleDebugThe debug APK will be at:
app/build/outputs/apk/debug/app-debug.apk
Run Spotless locally before each commit so format failures surface before CI:
brew install lefthook && lefthook installBefore opening a PR, run the active-app verification task:
npm --prefix dashboard-tests ci
./gradlew verifyActiveAppThis runs Android unit tests, lint, Spotless, debug assemble, JaCoCo coverage,
dashboard ESLint/typecheck/Vitest, Playwright e2e, bundle budgets, generated
dashboard drift, and migration straggler guards. See
docs/validation-matrix.md for what this proves
and which real-device/real-car checks still need separate evidence.
Dashboard tooling is validated on Node 24 in CI and enforced by Gradle's
dashboard npm tasks. Use the root .nvmrc or .node-version when setting up a
local shell.
The aggregate task is configuration-cache ready. For faster repeated local
loops, run ./gradlew verifyActiveApp --configuration-cache; the second run
should reuse the stored configuration.
verifyPerformance is the checked-in regression lane for dashboard bundle size,
startup budgets, and Android perf contracts. verifyStartupPerformanceOptional
adds optional local startup checks that are useful before performance-focused
PRs, but it is not required for every edit because host/device timing varies.
For phone-specific timing, use the local adb scripts against a connected device or wireless adb target:
bash tools/benchmark-adb-startup-local.sh <adb-serial-or-ip:port>
bash tools/benchmark-adb-tabs-local.sh <adb-serial-or-ip:port>
bash tools/device-baseline-local.sh <adb-serial-or-ip:port>These scripts target only com.volttracker.obdpoc. They do not install,
uninstall, clear app data, or mutate the database, so they are safe to run
against a field-test phone that already has permissions accepted. Startup and
tab reports are written under build/reports/adb-startup-benchmark/ and
build/reports/adb-tab-benchmark/, with trend JSONL files under
build/reports/performance-trends/.
The adb reports separate app-reported marks from host observation time:
appFirstFrameMsandappDashboardReadyProbeMsmeasure when the WebView dashboard has painted and exposed the ready probe from inside the app.jsTabSwitchMsandjsTabPaintMsmeasure tab switch handling and first paint from dashboard JavaScript marks.hostStartToReadyMsandhostStartToTabReadyMsinclude adb, process launch, UIAutomator polling, and tap overhead, so use them for trend comparisons, not as pure app work.
The tab benchmark uses coordinate taps by default because accessibility text can
make host-side tap latency look worse than the app. Set
VOLTTRACKER_TAB_TAP_STRATEGY=accessibility when you specifically need the
accessibility path.
Large database timing can be checked from a pulled or backup SQLite file:
bash tools/benchmark-real-db-local.sh /path/to/volttracker_obd_poc.dbKeep durable baseline snapshots in
docs/performance-baseline-history.md
and the metric contract in
docs/performance-contracts.md.
Run the no-network doctor when a new machine or agent shell behaves differently from CI:
./gradlew doctor
./scripts/doctor.shThese report Java, Android SDK, adb/device, Node/npm, dashboard dependency, and Playwright readiness without installing anything. The Gradle task fails on required runtime mismatches.
./gradlew :app:testDebugUnitTest # JVM/Robolectric unit tests
./gradlew :app:jacocoTestReport # coverage report (build/reports/jacoco/)
npm --prefix dashboard-tests run test:coverage # dashboard Vitest + Istanbul coveragejacocoTestCoverageVerification enforces ratcheting coverage floors a PR must
clear: 80% line coverage project-wide and 90% for the data/ package (the
append-only persistence layer is held to a higher bar). Additional focused
per-package and per-class floors guard the pure-logic modules (materialize,
classify, widget, and feature deciders like EventNotificationDecider) so a
regression there can't hide behind the project aggregate — see app/jacoco.gradle
for the current values. The dashboard Vitest suite has its own Istanbul thresholds
in dashboard-tests/vitest.config.js.
List outdated direct and transitive dependencies (advisory; nothing fails):
./gradlew dependencyUpdates -Drevision=releaseDependency update policy is in docs/dependencies.md.
Dependency snapshots and other dated audits are indexed in
docs/reports-index.md.
The scheduled Dependency snapshot workflow refreshes the Gradle release
dependency report plus dashboard/e2e npm outdated and audit artifacts weekly.
- Pair your OBD adapter in Android Bluetooth settings.
- Enable Developer Options and USB debugging.
- Plug in the phone and accept the USB debugging prompt.
- Run:
# macOS / Linux
adb devices
./gradlew :app:installDebug# Windows
adb devices
.\gradlew.bat :app:installDebugOptional phone mirroring:
scrcpy- The app intentionally keeps a small dependency surface. Runtime code directly
ships AndroidX Activity, AndroidX Core, and ProfileInstaller; test code uses
JUnit, Robolectric, and
org.json. - Bluetooth permissions are requested at runtime on Android 12+.
- A foreground service keeps the OBD session alive while polling.
- The WebView only loads local assets from
app/src/main/assets/dashboard. - The Map and Trips route views use remote CARTO basemap tiles by default, with OpenStreetMap fallback when CARTO is unavailable. Route, OBD, and GPS history still come from on-device storage, but tile providers can see requested tile coordinates.
- The service uses the standard ELM327 serial UUID:
00001101-0000-1000-8000-00805F9B34FB.
Fresh graded audits are written to .claude/grade-report.md, which is repo-local
and gitignored. Historical tracked reports remain under docs/.
Every connect or demo session writes JSONL logs on the phone under app-private storage:
files/obd-logs/session-*.jsonl
files/obd-logs/latest.txt
After reconnecting the phone with USB debugging (bash):
pkg="com.volttracker.obdpoc"
out="./field-test-latest.jsonl"
latest=$(adb shell run-as "$pkg" cat files/obd-logs/latest.txt | tr -d '\r')
adb exec-out run-as "$pkg" cat "files/obd-logs/$latest" > "$out"Or with PowerShell:
$pkg = "com.volttracker.obdpoc"
$out = ".\field-test-latest.jsonl"
$latest = adb shell run-as $pkg cat files/obd-logs/latest.txt
adb exec-out run-as $pkg cat "files/obd-logs/$latest" > $outThose logs include status transitions, connection failures, every ELM327 command and response, parsed telemetry samples, timing, empty responses, and whether the adapter prompt (>) was seen.
The SQLite database can also be pulled after a test (bash):
pkg="com.volttracker.obdpoc"
out="./field-test-db.db"
adb exec-out run-as "$pkg" cat databases/volttracker_obd_poc.db > "$out"Or with PowerShell:
$pkg = "com.volttracker.obdpoc"
$out = ".\field-test-db.db"
adb exec-out run-as $pkg cat databases/volttracker_obd_poc.db > $out