gzl.dev/blog
field notes, mostly
18 posts
August 27, 2026
7 min
Nine ways to say yes
I taught a Light Phone to speak an Oura ring's protocol over BLE. Then I spent a week failing to tap a button that doesn't exist.
Light PhoneBrightOuraBluetoothAndroid
August 21, 2026
5 min
Field notes on LightOS
What the Light Phone III's OS actually is underneath: Android with the Google removed, hardware that talks in key events, and conventions nobody voted on.
Light PhoneLightOSAndroid
August 21, 2026
1 min
A tape recorder, not a voice recorder
BrightRecorder: clips named for where and when, tapes that are just folders of WAVs, and the wheel finally doing the job it was born for.
Light PhoneBrightRecorder
August 18, 2026
2 min
Why I put an app store on a phone designed to have none
Yes, I see the irony. The argument for building it, why there's no server behind it, and the afternoon I accidentally dropped everyone's apps from the index.
Light PhoneBrightMarket
August 14, 2026
2 min
A good fork says thank you
A lot of what I ship is other people's work with my name nearer the top than it deserves. Some rules I try to fork by.
Light Phoneopen source
August 11, 2026
2 min
My bug reports file themselves
Shake the phone three times and the app files its own GitHub issue. Then an AI triages the queue every hour — under strict instructions that guessing is worse than waiting.
Light PhoneAItooling
August 8, 2026
2 min
Fifteen copies of the same file
The least interesting code I maintain is the code I'd save first in a fire. On finally building the shared library.
Light PhoneKotlin
August 8, 2026
2 min
BrightMarket is out
The app store ships: browse on a desktop, install by QR, track updates on the phone, and get your own app into the catalog.
Light PhoneBrightMarket
August 7, 2026
2 min
One column of scores
Twenty-two leagues from four keyless public APIs, in one grayscale column. How BrightSports gets scores with no account and no push.
Light PhoneBrightSports
August 6, 2026
3 min
The MetroCard inside my phone
I dissolved a transit card in acetone and moved its nervous system into the battery compartment. Tap-to-pay is back, sort of.
Light PhonehardwareNFC
August 6, 2026
2 min
Every button does what you tell it to
The follow-up to the keylayout post: BrightControl itself, why the defaults took longer than the code, and the trust problem with accessibility services.
Light PhoneBrightControldesign
August 5, 2026
1 min
Roll is out
The camera app ships: a filter wheel on real hardware, purikura with four-shot strips, date backs, and a privacy lesson from launch day.
Light PhoneRoll
August 4, 2026
2 min
The keylayout file Light left behind
The scroll wheel is just key events. Everything I learned reading Generic.kl off the phone, and the accessibility service that became BrightControl.
Light PhoneAndroid
August 2, 2026
3 min
Rewriting the camera
Roll's whole design is one gesture, stolen from a contact sheet. Plus the filters that got away from me — purikura on a monochrome phone, and datamoshing experiments that mostly produce mud.
Light Phonedesign
July 29, 2026
1 min
A QR scanner, and its desktop twin
The modern world hands you addresses as QR codes and the Light Phone couldn't read them. A scanner for the phone, a generator page for every other screen.
Light PhoneLightQR
March 22, 2026
1 min
BasilNet, a year later
What broke, what I'd trim, and how the media server quietly became infrastructure for everything else I build.
Dockerhomelabself-hosting
March 8, 2026
1 min
I built a bad DSLR on purpose
It took worse pictures than my phone. It's one of the most useful things I've ever built.
hardwarephotography
January 10, 2025
1 min
Building BasilNet: My Home Server Stack
How I built a 15-service media server with Docker Compose, Gluetun VPN, and Home Assistant integrations.
Dockerhomelabself-hosting
latest · August 27, 2026 · 7 min

Nine ways to say yes

Light PhoneBrightOuraBluetoothAndroid

An Oura ring measures all night and keeps none of it for you. It hands everything to Oura’s phone app, the app uploads it, and the app is where your data lives. On a phone with no Oura app, the ring is jewelry.

My phone is a Light Phone III. It has no Oura app, and it never will.

So I wrote one — most of one — and then hit a wall I still haven’t gotten past, in a place I didn’t expect. This is the writeup, because the failure turned out to be more interesting than the feature.

The protocol was the easy part

I assumed the reverse engineering would be the hard bit. It wasn’t, because Th0rgal already did it, pulling the protocol out of the official Android app’s native libraries and checking it against a Ring 3 and a Ring 5. The ring’s authentication is much smaller than its reputation:

get nonce       2f 01 2b            → 2f 10 2c <15-byte nonce>
authenticate    2f 11 2d <16 bytes> → 2f 02 2e 00   accepted

The ring holds a 16-byte key. Every connection it hands you a 15-byte nonce, you encrypt the nonce with the key, and you hand back the 16 bytes. That’s it. No key exchange, no certificate, no server. Two published descriptions of this float around and look different — “encrypt the nonce with PKCS5 padding” and “AES-128-ECB over the nonce plus a 0x01 byte” — and they’re the same operation, because PKCS#5 pads a 15-byte input with exactly one 0x01.

A week of evenings got me a scanner, a probe that reads firmware and serial without authenticating, a key installer, a history drain, and decoders for heart rate variability, inter-beat intervals, temperature and steps. Forty unit tests. The decoders have no Android imports at all, so I can run them against bytes I typed by hand instead of against a ring that produces one night per night.

Then I tried it on the phone.

Firmware and serial answer cold. Battery, history and the live streams answer 2f 02 2f 01 until the session authenticates. To authenticate you subscribe to a characteristic, subscribing is the first operation on the link that needs encryption, and encryption on Bluetooth means the phone and the ring have to be bonded.

A bond needs somebody to say yes.

Here’s what Android does with that. The pairing request goes out as a notification carrying a full-screen intent, and the platform expects the launcher to raise the dialog sitting behind it. LightOS doesn’t raise it. The phone chimes, nothing appears, the request expires in under a minute, and the bond fails with every layer working exactly as designed.

Fine, I thought. The dialog is an ordinary activity. I’ll start it myself.

It’s com.android.settings/.bluetooth.BluetoothPairingDialog, and on this phone that fragment builds a null dialog and dies with a NullPointerException in DialogFragment.prepareDialog. Not a permission problem, not my code. The screen that exists to accept a pairing request crashes when you ask it to accept a pairing request.

Nine routes

The whole search, in the order I tried it. Each one exists because the one above it failed.

  1. Wait for the prompt. No prompt is drawn, and most BLE bonds have no dialog at all, so I was waiting on an event that doesn’t happen.
  2. Answer the request myself with setPairingConfirmation(true). It throws. The call wants BLUETOOTH_PRIVILEGED, which is signature-level and can’t be granted over adb.
  3. Start the Settings dialog directly. That’s the crash above.
  4. Pair by hand in Settings. AOSP Settings crashes on the pairing screen. LightOS has its own Bluetooth screen, which pairs earbuds happily, and it says “pairing” and then stops.
  5. Press the notification’s Pair button from a listener service. A notification action runs as the app that created it, so this needs no permission at all, and I liked this one. The button starts the activity from step 3.
  6. pm clear com.android.settings, in case a crashing Settings never posted the request in the first place. Reversible, worth ruling out, changed nothing.
  7. The companion device flow. CompanionDeviceManager is a different shape: the app asks, the system runs a device picker, and the picker comes back as an intent the app launches itself — an activity, on a phone that draws no notifications. The platform even skips the consent dialog when the bond is requested by an app holding a fresh association for the same address. Two surprises there: createBond records no caller when a bond is already in flight, and the watch profile bonds from the system at association time, which makes your own call a no-op with nobody on file.
  8. Ask with the screen off. This one is real. BluetoothPairingRequest only starts the dialog activity while the phone is interactive, and asleep it posts the notification instead. Which is step 5.
  9. Give up on the phone’s UI entirely.

The shell holds the permission

com.android.shell has BLUETOOTH_PRIVILEGED, granted and privileged, along with BLUETOOTH_STACK. The adb shell is the one process on this phone that can answer a pairing request with no interface whatsoever, and I already have an app that holds an adb shell over wireless debugging, because I wrote one.

So the pairing helper is a plain main class living inside my own APK, which is world-readable, run through that shell:

CLASSPATH=/data/app/…/base.apk app_process / com.gios.brightoura.helper.Confirm <MAC>

It asks for the bond and answers the request it raised, in one loop, as one uid. Getting a working Bluetooth adapter inside a bare app_process took four discoveries, and not one of them throws when it’s missing:

  • Bluetooth is a mainline module since Android 13, and its service wrapper is registered during application startup. A plain main class is not an application, so getDefaultAdapter() politely returns null. You construct and install the service manager yourself.
  • The adapter’s binder arrives on a callback posted to the main looper. Prepare the looper and never run it, and every bond operation returns false and says nothing.
  • AdapterService.createBond checks your package name against your uid, so the adapter needs an attribution source claiming uid 2000 and com.android.shell.
  • Exactly one construction route lets you set that attribution source.

Four evenings. Then this came back:

createAdapter(shell): ok
attributed to com.android.shell (uid 2000) via createAdapter
createBond true
state BONDING
setPairingConfirmation true
waiting… 51s left, state BONDING
…
state NONE
RESULT refused after confirming

Read it from the top. The adapter works. The bond starts. The confirmation goes through. The two ends exchange keys for twenty-seven seconds. Then the bond falls apart, and my own helper reports the least useful sentence available to it.

I never asked why

ACTION_BOND_STATE_CHANGED carries EXTRA_REASON — a number that says whether the keys mismatched, the ring rejected the phone, the ring went out of range, the exchange timed out, or the stack is refusing because too many pairings have failed recently. My helper had never once asked for it. It read the state afterward, found a bare NONE, and printed “refused after confirming,” which is an honest answer to the wrong question.

I have a strong suspicion about which reason it is, and there’s no drama in it at all: after a dozen failed attempts in an evening, the Bluetooth stack starts refusing on a counter, and the remedy is to turn Bluetooth off and on and wait a minute. If that’s what it says, then every attempt in the most interesting session of the week was being judged by a timer rather than on its merits.

That’s where the project is parked. The version that prints the reason is built and shipped and I haven’t run it yet, which says more about how projects go than about Bluetooth.

What the failure was worth

Three things came out of it that the success wouldn’t have produced.

A Ring 4 Bluetooth table nobody appears to have published. The protocol notes describe two characteristics. My ring has five, plus a second service nobody has written about, and one of the undocumented ones reads and writes and notifies — which is what a request/response channel looks like when it doesn’t want a subscription. If that channel answers, the whole bonding wall becomes irrelevant, and writing one request to it is the cheapest thing left to try.

A decoder that can be corrected. Every frame the ring gives up is stored as raw bytes, decoded or not, because the ring’s buffer is finite and what it hands over once it won’t hand over twice. The temperature and step layouts are inferred rather than documented, and the code says so on every reading it produces. The first real night of data gets to correct them, instead of quietly confirming a scaling error.

A rule I keep relearning. LightOS is Android with pieces of the user-facing platform absent, and the absences don’t announce themselves. The same week as all this, every one of my Light SDK tools with a text field in it started crashing on a phone update, because the reply to “what should the keyboard look like” gained a field and lost a field, and decoding that reply was the one step in the call nobody had guarded. A screen that only wanted to draw a keyboard took the whole process down with it.

Both failures have the same shape: the contract with the platform moves, and the code finds out in the least helpful way available. The habit that pays isn’t defensive coding in the abstract. It’s making the failure legible at the moment it happens, and keeping the bytes. Two saved transcripts are the only reason this story has a next step.

If you want to take it further

Everything is in BrightOura, MIT, and the handoff document is the real artifact: what works, all nine routes with the mechanical reason each one is closed, the framework discoveries, the transcripts, and six ranked next steps written as what each would prove. Two of them can end the project honestly, which is worth more than a tenth route.

If you have a Ring 4 and a phone whose Settings app doesn’t crash, you can settle in an afternoon what I couldn’t settle in a week.