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.
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.
The whole search, in the order I tried it. Each one exists because the one above it failed.
setPairingConfirmation(true). It throws. The call wants BLUETOOTH_PRIVILEGED, which is signature-level and can’t be granted over adb.pm clear com.android.settings, in case a crashing Settings never posted the request in the first place. Reversible, worth ruling out, changed nothing.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.BluetoothPairingRequest only starts the dialog activity while the phone is interactive, and asleep it posts the notification instead. Which is step 5.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:
getDefaultAdapter() politely returns null. You construct and install the service manager yourself.AdapterService.createBond checks your package name against your uid, so the adapter needs an attribution source claiming uid 2000 and com.android.shell.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.
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.
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.
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.