Dev lukmandotdev had a problem familiar to anyone sitting on beefy hardware they barely use. A Lenovo Legion Tab TB321FU—armed with Android 16, rooted via Magisk, and living in Termux—was doing maybe ten percent of what it could. So the challenge was simple: make that tablet talk directly to his car. Read ECU data like RPM, speed, coolant temperature, throttle position—all through a cheap ELM327 Bluetooth dongle plugged into the OBD-II port. Display it big on screen. And here's the constraint that made this interesting: zero apps installed. Not Torque, not APK sideloads, nothing. Pure shell from Termux, with Claude Code CLI acting as coding assistant throughout.
The Linux Instinct That Hit a Wall Immediately
First move was automatic for anyone used to Linux—open an RFCOMM Bluetooth socket via Python. Five lines, done. Except: AttributeError. Python in Termux doesn't have AF_BLUETOOTH. Checked for rfcomm, hcitool, sdptool in the usual spots. Nothing. Turns out Android doesn't run BlueZ at all—it uses a Bluetooth stack called Fluoride. The only door in is through Java Android's Bluetooth API, which normally requires being inside an application with BLUETOOTH_CONNECT permission.
app_process: Android's Backdoor to Runtime
Here's where it gets interesting. Every Android app starts via something called app_process—a runtime binary you can actually call from shell directly. The plan: write a tiny Java program, compile it into DEX using javac and d8, then run it through app_process. It runs outside any app but inside the full Android runtime with Bluetooth access. The architecture bridges ELM327 over BT SPP to a TCP socket at 127.0.0.1:35000—behaving exactly like an ELM327 WiFi adapter while hiding all the Fluoride complexity behind standard TCP. A Node.js server and web dashboard sit on top of that port.
Six Traps, One Solution (Sort Of)
The execution revealed six consecutive gotchas, each teaching something fundamental about Android internals. Trap one: don't run as root. Root has no package identity, so getAdapter() returns null. BLUETOOTH_CONNECT is tied to uid 2000—shell—not uid 0. The correct invocation is su 2000 -c '...', not the other way around where Magisk interprets '2000' as an argument. Trap two: hidden API blocking. Internal API calls get rejected outright. Solution is dalvik.system.VMRuntime.getRuntime().setHiddenApiExemptions("L"); Trap three: no Looper. Shell processes don't have Android's main thread loop, so context and adapter calls throw Looper errors. Must create manually with android.os.Looper.prepareMainLooper(); Trap four—the most hidden: on Android 14+, a static is null in non-app processes. Not documented anywhere. Had to dig into AOSP source code to find it. The fix: android.bluetooth.BluetoothFrameworkInitializer.setBluetoothServiceManager(new android.os.BluetoothServiceManager()); Then finally adapter acquisition via ActivityThread.systemMain().getSystemContext().getSystemService("bluetooth").getAdapter(); and insecure RFCOMM socket creation using the classic SPP UUID. RFCOMM CONNECTED appears in logs.
The Mystery Disconnect (Spoiler: It Was GC)
Connected successfully, then dropped at exactly 0.9 seconds. Reconnect, drop again at 0.9s. Like a metronome. Suspect number one was the cheap clone dongle—symptoms looked like hardware failure. Hours of blaming the dongle followed. Different timeouts, different connect methods, keepalive commands. The 0.9-second pattern never changed. But its regularity eventually triggered suspicion: hardware failures aren't that clean. Something with a timer or lifecycle was at play. Garbage collector came to mind. Normal Android apps keep Bluetooth objects alive for the app's lifetime. In this shell process, once setup functions completed and connect() returned, JVM saw ActivityThread, Context, BluetoothManager, and BluetoothAdapter as unused—garbage. When those objects holding the registered adapter got collected about a second later, Android's Bluetooth system reap'd the RFCOMM link regardless of active data flow. The fix was embarrassingly simple: strong references in static fields to prevent GC from cleaning up framework objects. One line of discipline that meant the difference between "this dongle is broken" and an all-day stable connection.
Clone Dongle Personality Traits
With GC handled, the clone had its own quirks to learn. Never send ATZ—that standard ELM327 reset command resets the chip and drops the Bluetooth link with a Broken pipe error. The bridge deliberately never sends it. Clone also drops idle links after about 0.7 seconds, so keepalive (single carriage return) runs until a client connects. After rapid connect-drop cycles during debugging, the clone can hang permanently—accepting connections then dropping in milliseconds. Only fix is unplugging and replugging from the OBD-II port for ten seconds.
Building the Dashboard
With the bridge stable, everything else was standard web stack—and honestly the fun part. A plain Node.js server without frameworks: transport.js handles TCP connection with built-in simulator generating realistic fake data through idle, acceleration, and cruise cycles (enabling full dashboard layout work anywhere); obd.js speaks ELM327 protocol and auto-detects supported PIDs; server.js serves HTTP and pushes values to browser via SSE for realtime updates. Dashboard is pure static web. All libraries bundled locally for offline operation in the car. GridStack.js handles drag-and-resize, canvas-gauges handles gauges (though numeric cards are default), uPlot renders live graphs. Added PWA capability for "Add to Home screen" fullscreen app experience. The design philosophy: big readable numbers at a glance while driving, not pretty dial speedometers that take effort to parse. Default widgets are large numeric cards with value, mini-bar, min/max, and color zones in a dense draggable grid via GridStack. Edit mode locked by default for safety.
Real Car, Real Data
Tablet goes in the car, ELM327 clone plugs into OBD-II port, ignition to ON (powers the dongle). From Termux: ./start.sh with target set to the default "OBDII" device. Bridge starts as uid 2000, sets BluetoothServiceManager OK, gets adapter, connects to OBDII, RFCOMM CONNECTED appears—and this time stays connected thanks to those static references. Open dashboard in Chrome, negotiate PIDs with ECU over a few seconds, badge changes from "waiting" to "LIVE", and those big cards aren't showing simulation anymore. Actual RPM idle of the car engine. Coolant temperature climbing gradually as it warms up. Blip the throttle and RPM jumps on screen instantly—no lag.
Drag Timer and Virtual Dyno
With real-time RPM and speed locked in, scope creep was inevitable. First addition: drag timer running server-side via drag.js. Press ARM, release to launch when car moves. Automatically calculates distance from speed trace, records 60ft split, 0-100 km/h, 201m (1/8 mile), 402m (1/4 mile) with trap speeds. Run auto-saves at 402 meters. During active runs server focuses on polling RPM and speed only for maximum resolution. Sample real run captured: {"peakSpeed":107,"distance":403.5,"duration":18.77, "splits":{"60ft":{"t":2.19,"v":36},"0-100kmh":{"t":10.8,"v":100},"201m":{"t":11.03,"v":101},"402m":{"t":18.71,"v":74}}} Second addition: virtual dyno running client-side from run traces. Calculates HP and torque vs RPM using inertial method—force equals mass times acceleration plus aerodynamic drag and rolling resistance. User sets vehicle mass and drivetrain loss percentage in settings menu. One launch gives you both dragstrip timeslip and dyno curve without any additional equipment.
Key Takeaways
- Android is a completely different world from Linux regarding Bluetooth—forget BlueZ, go through Java Android API with app_process as the bridge
- uid 2000 (shell) has BLUETOOTH_CONNECT permission, not root—sometimes having less privilege is exactly right
- Too-clean disconnects are a signature of garbage collection, not hardware failure—one well-placed strong reference prevented hours more of dongle-blaming
The Bottom Line
This project proves that "zero apps installed" constraints force creativity in ways that reveal how systems actually work under the hood. Six reflection traps, one GC masquerading as hardware failure, and a clone dongle with personality—all standing between an expensive tablet doing nothing and it becoming a legitimate car computer. The root decision at the start unlocked both: full hardware access for the device, and full execution capability for the AI coding assistant helping throughout.