Open-source Android OBD2 scanner sample app showing a production-style integration of
kotlin-obd-api with modern Android architecture.
This repository is intended to be a canonical reference for developers who need to implement:
- Bluetooth ELM327 discovery, pairing, and connection on Android (SPP / Bluetooth Classic)
- Live OBD-II PID polling (speed, RPM, coolant, throttle, etc.)
- Vehicle diagnostics (VIN + DTC parsing + clear DTC)
- Jetpack Compose + Hilt + DataStore + Flow architecture
- Quality checks and observability (Lint, ktlint, detekt, unit tests, debug logs, telemetry)
- Overview
- Quick Start Guide
- Features
- Tech Stack
- Project Structure
- Architecture
- Bluetooth + OBD Integration Deep Dive
- OBD Commands Used
- Permissions & Security Model
- State Management
- Settings & Persistence
- Setup Guide
- Build & Run
- Quality Gates
- Testing
- Compatibility Notes
- Troubleshooting
- Contributing
Many OBD Android repos are either:
- outdated examples,
- tightly coupled codebases,
- or missing architecture/testing guidance.
obd-scanner-android focuses on being:
- current (Kotlin + Compose + modern Android tooling),
- educational (clear architecture boundaries),
- practical (real Bluetooth + OBD command flow),
- extensible (easy to add more commands/transports).
If you're building an app around ELM327, vehicle telemetry, or Android diagnostics tools, this repo is designed to be your starting point.
At a high level, the app is organized around four main user flows:
- Connection: discover nearby Bluetooth Classic devices, pair adapters, and establish the RFCOMM/OBD session
- Dashboard: read live vehicle metrics with a polling scheduler tuned for fast/medium/slow sensor updates
- Diagnostics: read VIN + trouble codes and clear DTCs without competing with dashboard polling
- Settings: persist theme, polling interval, auto-connect, and telemetry preferences
Under the hood, the project keeps UI, domain, and data responsibilities separate while still staying practical for a real Android Bluetooth + OBD implementation.
This sample currently targets Bluetooth Classic (SPP) adapters. BLE transport is not implemented in this repository.
| Connection | Dashboard |
|---|---|
![]() |
![]() |
| Diagnostics | Settings |
|---|---|
![]() |
![]() |
If you're new to Android OBD2 apps, start here first. This section is intentionally practical and step-by-step.
- An Android phone/tablet with Bluetooth
- An ELM327-compatible Bluetooth Classic (SPP) OBD-II adapter
- A vehicle with an OBD-II port
- Android Studio + JDK 17 (if you want to build from source)
./gradlew assembleDebugInstall and open the app from Android Studio (or install APK via adb).
Before using the app:
- Open the app's Connection screen
- Grant the requested Bluetooth permissions
- Use Scan Nearby to discover nearby adapters
- If your adapter is not already bonded, tap Pair from the nearby devices list and confirm the Android Bluetooth dialog
- Return to the app and select the paired adapter
The app discovers nearby Bluetooth Classic devices, initiates system pairing for nearby devices, and connects to paired devices for the RFCOMM/OBD session.
- Open Connection screen
- Grant requested Bluetooth permissions
- Optionally tap Scan Nearby to confirm your adapter is visible
- Select your paired OBD adapter
- Tap Connect
- Once connected, open:
- Dashboard for live metrics (speed/RPM/etc.)
- Diagnostics for VIN and DTCs
- “Device not listed”
- usually means adapter is not paired at OS level
- “Connected but no values”
- some ECUs do not expose all PIDs
- try another vehicle or increase polling interval
- “Permission denied / security exception”
- re-open app settings and allow Bluetooth permissions
- OBD-II: standard vehicle diagnostics interface
- ELM327: common adapter chipset used by Bluetooth OBD dongles
- PID: parameter ID used to request sensor data (e.g., speed)
- DTC: Diagnostic Trouble Code (fault code)
- VIN: Vehicle Identification Number
- MIL: Malfunction Indicator Lamp (check-engine status)
The remaining sections in this README cover the implementation in depth, including:
- architecture layers (UI/domain/data)
- repository + transport internals
- kotlin-obd-api command integration
- quality tooling and testing strategy
- Runtime permission request flow for Bluetooth discovery and connection
- List paired devices
- Scan nearby Bluetooth Classic devices from inside the app
- Start Bluetooth pairing for nearby devices from inside the app (system pairing dialog)
- Show pairing progress and refresh paired devices when bonding completes
- Connect/disconnect to ELM327-compatible adapter
- Connection state (
Disconnected,Connecting,Connected,Error) - Discovery state (
Idle,Starting,Discovering,Finished,Error) - Pairing state (
Idle,Pairing,Paired,Error)
- Tiered polling loop with configurable base interval
- Live gauges and sensor cards
- In-app debug log viewer (command/response/error timeline)
- Export current debug logs to a
.txtfile via the system document picker - Optional structured telemetry capture for command timing, cycle summaries, and metric emissions
- VIN retrieval
- Trouble code retrieval (Mode 03)
- Trouble code clearing (Mode 04)
- Robust DTC parser:
- direct standard-code extraction (
[PCBU][0-3][0-9A-F]{3}) - hex fallback decoding to canonical 5-char codes
- filters noise (
NO DATA,P0000, duplicates)
- direct standard-code extraction (
- Polling interval configuration
- System/light/dark theme selection
- Auto-connect to previous device
- Telemetry logging toggle
- Language: Kotlin
- UI: Jetpack Compose + Material 3
- DI: Hilt + KSP
- Concurrency: Coroutines + Flow
- Persistence: DataStore Preferences
- OBD Library:
com.github.eltonvs:kotlin-obd-api:v1.4.1 - Static Analysis: Android Lint + ktlint + detekt
- Testing: JUnit4 + MockK + Turbine + coroutines-test
Build configuration:
- Compile SDK:
36 - Min SDK:
24 - Java target:
17
app/src/main/kotlin/studio/etsoftware/obdapp/
├── data/
│ ├── connection/
│ │ ├── BluetoothDiscoveryManager.kt
│ │ ├── BluetoothTransport.kt
│ │ └── ObdTransport.kt
│ ├── diagnostics/
│ │ ├── DiagnosticsService.kt
│ │ └── DtcParser.kt
│ ├── di/
│ │ ├── LoggingModule.kt
│ │ ├── RepositoryModule.kt
│ │ ├── SettingsModule.kt
│ │ ├── TelemetryModule.kt
│ │ └── TransportModule.kt
│ ├── logging/
│ │ ├── DebugLogRepositoryImpl.kt
│ │ ├── LogExporter.kt
│ │ ├── LogExportFormatter.kt
│ │ ├── LogExportRepositoryImpl.kt
│ │ └── LogManager.kt
│ ├── polling/
│ │ ├── DashboardMetricsStore.kt
│ │ ├── DashboardPollingCoordinator.kt
│ │ └── DashboardPollingScheduler.kt
│ ├── repository/
│ │ ├── ConnectionRepositoryImpl.kt
│ │ ├── DashboardRepositoryImpl.kt
│ │ ├── DiagnosticsRepositoryImpl.kt
│ │ └── DiscoveryRepositoryImpl.kt
│ ├── session/
│ │ ├── ObdCommandExecutor.kt
│ │ └── ObdSessionManager.kt
│ ├── settings/
│ │ ├── AppSettingsRepositoryImpl.kt
│ │ ├── PollingSettingsRepositoryImpl.kt
│ │ └── PreferencesManager.kt
│ └── telemetry/
│ ├── TelemetryRecorder.kt
│ ├── TelemetryRepositoryImpl.kt
│ └── TelemetrySettingsDataSource.kt
├── domain/
│ ├── model/
│ │ ├── DashboardMetricsSnapshot.kt
│ │ ├── DebugLogEntry.kt
│ │ ├── DeviceInfo.kt
│ │ ├── DiagnosticInfo.kt
│ │ ├── DiscoveryState.kt
│ │ ├── PairingState.kt
│ │ ├── TelemetryEvent.kt
│ │ └── VehicleMetric.kt
│ ├── repository/
│ │ ├── AppSettingsRepository.kt
│ │ ├── ConnectionRepository.kt
│ │ ├── DashboardRepository.kt
│ │ ├── DebugLogRepository.kt
│ │ ├── DiagnosticsRepository.kt
│ │ ├── DiscoveryRepository.kt
│ │ ├── LogExportRepository.kt
│ │ ├── PollingSettingsRepository.kt
│ │ └── TelemetryRepository.kt
│ └── usecase/
├── ui/
│ ├── components/
│ ├── feature/
│ │ ├── connection/
│ │ ├── dashboard/
│ │ ├── diagnostics/
│ │ └── settings/
│ ├── navigation/
│ │ ├── NavGraph.kt
│ │ └── Screen.kt
│ ├── theme/
│ └── MainActivity.kt
└── ObdScannerApp.kt
Layered architecture with stricter dependency direction:
- UI layer: composables + ViewModels that depend on use cases and domain models only
- Domain layer: focused repository contracts + use cases + domain models
- Data layer: Android transport/platform integrations + repository implementations + orchestrators/services + observability/persistence details
Core OBD flow now goes through focused repository contracts instead of a single broad gateway:
Compose Screen
-> ViewModel
-> UseCase
-> ConnectionRepository / DiscoveryRepository / DashboardRepository / DiagnosticsRepository
-> repository impls
-> session / polling / diagnostics services
-> kotlin-obd-api commands + Bluetooth transport
The data layer itself is decomposed into role-based collaborators:
ConnectionRepositoryImpl
-> BluetoothDiscoveryManager
-> ObdSessionManager
-> ObdCommandExecutor
-> ObdTransport (BluetoothTransport)
DashboardRepositoryImpl
-> DashboardPollingCoordinator
-> DashboardPollingScheduler
-> DashboardMetricsStore
DiagnosticsRepositoryImpl
-> DashboardPollingCoordinator.runWithPollingPaused(...)
-> DiagnosticsService
-> DtcParser
Debug log export and telemetry inspection are also routed through domain-facing contracts:
DashboardScreen
-> DashboardViewModel
-> ObserveDebugLogsUseCase / BuildLogExportTextUseCase / ExportLogsUseCase / ObserveTelemetryEventsUseCase
- UI remains free from transport, DataStore, and logging implementation details
- repository contracts are focused by responsibility, which improves testability and change isolation
- polling, diagnostics, session lifecycle, logging, and settings each have explicit owners in the data layer
- Android-specific APIs stay at the edges while business-facing flows stay in domain/use cases
- User taps Scan Nearby in
ConnectionScreen ConnectionViewModel.startDiscovery()invokesStartDiscoveryUseCaseDiscoveryRepositoryImpldelegates toBluetoothDiscoveryManager, which:- validates Bluetooth scan/connect permissions
- registers a short-lived broadcast receiver for discovery events
- starts Classic Bluetooth discovery via
BluetoothAdapter.startDiscovery() - emits
DiscoveryStateupdates as devices are found
- The ViewModel filters out already-paired devices and renders nearby unpaired devices separately
- Nearby unpaired devices can start Android's system Bluetooth pairing flow directly from the app, with bond-state progress reflected in
PairingState
- User selects a paired adapter in
ConnectionScreen ConnectionViewModel.connect()invokesConnectDeviceUseCaseConnectionRepositoryImpl.connect()stops any active discovery and delegates session setup toObdSessionManagerObdSessionManager.connect():- validates
BLUETOOTH_CONNECTpermission - delegates socket creation/connection to
BluetoothTransport BluetoothTransportdefensively cancels discovery before RFCOMM connect- creates
ObdDeviceConnection(inputStream, outputStream) - performs adapter bootstrap through
ObdCommandExecutor:ATZ(ResetAdapterCommand)ATE0(SetEchoCommand(Switcher.OFF))
- cleans up partially-open sessions if bootstrap fails
- validates
- Dashboard starts polling only when connected
DashboardRepositoryImpldelegates polling lifecycle toDashboardPollingCoordinatorDashboardPollingCoordinatorexecutes on an IO coroutine scopeDashboardPollingSchedulerdecides which metrics are due using fast/medium/slow tiers instead of sending every command every cycle- Polling interval changes can be applied while the polling loop is active
- Transport loss and disconnect both stop polling safely and update connection state through the session layer
DiagnosticsViewModelowns diagnostics screen state- Reads VIN and trouble codes on demand / when connected
- Can clear trouble codes through Mode 04
DiagnosticsRepositoryImplruns diagnostics throughDashboardPollingCoordinator.runWithPollingPaused(...)so diagnostics do not race with dashboard pollingDiagnosticsServiceperforms VIN / DTC command executionDtcParserhandles resilient DTC normalization before UI rendering
| Purpose | Command Class | Typical PID/Mode |
|---|---|---|
| Reset adapter | ResetAdapterCommand |
ATZ |
| Echo off | SetEchoCommand(Switcher.OFF) |
ATE0 |
| Vehicle speed | SpeedCommand |
010D |
| Engine RPM | RPMCommand |
010C |
| Coolant temp | EngineCoolantTemperatureCommand |
0105 |
| Intake air temp | AirIntakeTemperatureCommand |
010F |
| MAF | MassAirFlowCommand |
0110 |
| Throttle position | ThrottlePositionCommand |
0111 |
| Fuel level | FuelLevelCommand |
012F |
| VIN | VINCommand |
Mode 09 |
| Trouble codes | TroubleCodesCommand |
Mode 03 |
| Clear trouble codes | ResetTroubleCodesCommand |
Mode 04 |
Manifest permissions:
BLUETOOTH/BLUETOOTH_ADMIN(for API <= 30)BLUETOOTH_CONNECTBLUETOOTH_SCAN(neverForLocation)ACCESS_FINE_LOCATION(API <= 30 only, for discovery)
Runtime permission request is handled in ConnectionScreen.
Runtime behavior:
- API 31+: request
BLUETOOTH_SCAN+BLUETOOTH_CONNECT - API <= 30: request location permission when starting Bluetooth discovery
- API <= 30: Bluetooth discovery also requires system Location services to be turned on
Defensive checks in data layer:
- repository/discovery/transport guard sensitive calls with permission checks
- sensitive Bluetooth calls handle
SecurityExceptiongracefully - discovery receiver is registered only for active scan sessions and unregistered when scanning finishes/stops
- pairing and connection flows stop discovery before attempting RFCOMM work
ConnectionRepositoryexposesStateFlow<ConnectionState>for connection statusDiscoveryRepositoryexposesStateFlow<DiscoveryState>andStateFlow<PairingState>for Bluetooth discovery/pairing progressDashboardRepositoryexposesStateFlow<DashboardMetricsSnapshot>for dashboard-friendly metric snapshots- Low-level metric events are still produced in the data layer via
DashboardMetricsStore TelemetryRepositoryexposes telemetry events through dedicated use cases when enabled- Screens consume state with
collectAsStateWithLifecycle()
This keeps UI reactive and lifecycle-safe.
PreferencesManager (DataStore, kept in the data layer) stores:
- polling interval
- theme (
systemdefault) - last connected device metadata
- auto-connect flags
- telemetry enabled state
- previous-connection state used by the current auto-connect flow
Those values are surfaced to the rest of the app through domain-facing contracts such as AppSettingsRepository, PollingSettingsRepository, and TelemetryRepository.
Auto-connect flow:
- dashboard startup checks persisted state through use cases before attempting reconnect
- if enabled and a valid previous device exists, reconnect attempt is triggered
- Android phone/tablet (Bluetooth capable)
- ELM327-compatible OBD-II adapter (Bluetooth Classic/SPP)
- Vehicle with OBD-II port
- Open app and grant requested permissions
- Use Scan Nearby to confirm the adapter is visible
- Tap Pair for a nearby device and confirm the Android Bluetooth pairing dialog
- The app refreshes the paired list when bonding completes; select the paired adapter and connect
./gradlew assembleDebugThen install via Android Studio or adb install.
Run full quality pipeline:
./gradlew checkRun individually:
./gradlew app:testDebugUnitTest
./gradlew app:lint
./gradlew app:ktlintCheck
./gradlew :app:detekt
detektis wired as a custom Gradle task usingdetekt-clifor compatibility with the current AGP/Kotlin setup.
Current test coverage includes:
- focused use case behavior against domain contracts
- ViewModel state transitions and error handling
- dashboard polling scheduler behavior
- dashboard metrics publication/state snapshot behavior
- polling coordinator pause/access behavior
- diagnostics service behavior
- DTC parser normalization/hex decoding
- debug log export formatting
Frameworks:
junit:junitio.mockk:mockkkotlinx-coroutines-testapp.cash.turbine
- This sample targets Bluetooth Classic (SPP) transport
- BLE transport is not implemented in this repository
- Not all ECUs expose all PIDs; missing sensor values may be expected on some vehicles
Toolchain note:
- AGP/KSP + built-in Kotlin currently requires compatibility handling in this project (
android.disallowKotlinSourceSets=false)
- ensure Bluetooth is enabled on the phone/tablet
- on Android 11 and below, ensure system Location is turned on before tapping Scan Nearby
- on Android 11 and below, ensure Location permission is granted before scanning
- tap Scan Nearby and wait for discovery to finish
- if the adapter appears under nearby devices, tap Pair and complete the Android Bluetooth pairing dialog
- verify Bluetooth permissions are granted
- ensure adapter has power (ignition state)
- ensure the adapter is paired before connecting
- ensure no other app is currently connected to adapter
- unplug/replug adapter and retry
- ECU may not support specific PIDs
- increase polling interval and retry
- test with another compatible vehicle
- ensure JDK 17
- run
./gradlew clean check - sync Gradle in Android Studio after dependency updates
Contributions are welcome.
- Open an issue to explain the problem you want to solve: Open an issue
- For larger changes, discuss the approach first, then open a PR (or draft PR): Current PRs
- Run local verification before opening a PR:
./gradlew clean check - Add or update tests for feature work and behavior changes




