Field note · Embedded Android · BLE
Embedded Android: shipping a 10-inch console used in $500M of equipment.
From 2014 to 2017 I led Android development on the Octane Fitness Smart Console — a 10-inch capacitive touchscreen embedded in six commercial machine lines (XT-One, ZR8000, Lateral X, XR6000, XT3700, XT4700). It was at CES. It powered roughly half a billion dollars in equipment sales. It is still in production after Octane's acquisition by TRUE Fitness. These are the decisions that made it stick.
The first decision: stop pretending it's a phone
The console inherited an Angular-based hybrid app running in a WebView. On paper that was reasonable — fast to iterate, web devs available. In practice the device was on twelve hours a day, garbage collected on its feet, and was responsible for actual physical hardware. WebView animations stuttered when the user adjusted resistance. Tap latency was visible. The bridge between JS and the machine controller was a black box of dropped messages.
We migrated to fully native Android (Java first, then Kotlin) targeting the embedded SoC directly. UI in stock Android views, BLE in BluetoothGatt, machine I/O via the NDK. The migration took the better part of a year. Performance, hardware integration depth, and crash rate all improved by an order of magnitude.
If you take one thing from this post: WebView is a fine choice for content surfaces, never for hardware control surfaces.
The second decision: own the BLE library
The machine controller spoke a vendor BLE protocol with three custom GATT services — workout telemetry (calories, distance, speed, RPM), resistance control (write target, read actual), and OTA. We had to talk to it 20+ times per second during a workout. The off-the-shelf reactive BLE wrappers we tried added latency, hid errors, and assumed phone-app patterns that didn't apply.
We wrote our own thin library on top of BluetoothGatt. Three rules:
- One operation in flight, ever. A serial executor wrapping every read, write, and notification setup. The Android stack is single-threaded under the hood; pretending it isn't is the source of half of all "intermittent BLE bugs."
- Hot stream of measurements, cold stream of control. Telemetry is a fire-hose Flow that the UI subscribes to. Resistance/incline/gradient writes are explicit one-shots that resolve to a confirmed-applied event from the controller.
- Every operation has a hard timeout. If a write doesn't ack in 250 ms, fail it loudly. A 30-second hang on resistance change in front of a paying gym member is unacceptable.
OTA: signed, resumable, atomic
The OTA pipeline pushed firmware to the machine controller through the console. Because failure was expensive — a bricked controller meant a service truck visit — three properties were non-negotiable:
- Signed. Each firmware blob shipped with a manufacturer-signed manifest. The console verified the signature before sending the first byte over BLE.
signature=okin the log was a hard gate. - Resumable. A full firmware image over BLE is a long write — minutes, not seconds, even under favorable connection parameters. Members lean on consoles. The wire protocol carried per-chunk sequence numbers so a dropped link picked up at the next boundary, not from zero.
- Atomic activation. Bytes were written to a staging slot. The "switch to new firmware" step was a single-byte command at the end. Either the controller booted into the new image successfully, or it rolled back. No half-flashed states.
The boring discipline of staging + atomic switch is what kept us out of the support backlog. None of it is hard to implement; all of it is easy to skip.
Persistence: the device is the source of truth
A console is not a phone. There's no user account on the box. Members swipe a key fob, do their workout, and the data needs to live somewhere reliable until the gym's network sync window. We used SQLite directly with a small repository layer; a write returned only after a successful fsync. The cloud sync was a separate process that operated on rows that had already landed durably on disk.
The pattern: every external system is best-effort, the device is authoritative. Reverse those defaults and you'll lose data the first time a gym's WiFi has a bad day.
What I'd do differently today
- Start in Kotlin. We started in Java and migrated mid-project. Six months of work that gave us nothing the user could see. If I were starting today, Kotlin from line one, no question.
- Compose for the UI. Stock Android views were the right call in 2015. Today, Compose-on-embedded with proper hardware acceleration is the move — easier state modeling for a stateful piece of hardware.
- Tighter telemetry budget. We initially logged everything. Embedded Android with weeks of uptime accumulates astonishing log volumes. Define the SLI you actually care about up front and discard the rest.
- Plan the kiosk story earlier. Locking the device down — disabling status bar, recents, system gestures — is non-trivial on consumer Android builds and was a late-stage scramble. Build it in from week one or pick an OEM that ships a kiosk-ready image.
Bottom line
Embedded Android lives in the gap between two professional cultures. Phone-app teams underestimate the rigor that hardware demands; firmware teams underestimate how much app polish a touchscreen surface needs. The work is rarely glamorous — it's tight loops around BLE, careful persistence, and OTA boredom — but the result is a product that runs unattended for years.