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
← all posts
August 21, 2026 · 5 min

Field notes on LightOS

Light PhoneLightOSAndroid

After a few dozen apps and a lot of evenings with adb, I’ve accumulated notes on what LightOS — the Light Phone III’s operating system — actually is under the surface. None of this is secret. All of it was learned the slow way, one confused evening at a time, and it’s scattered across my repos in README paragraphs that assume you already care. This is the collected version, so the next person doesn’t have to spend the evenings I did.

It’s Android 14, and that’s a feature

LightOS is Android 14 underneath, with a locked bootloader. No jailbreak, no unlock, no exotic toolchain — adb install works the way it works everywhere, and ordinary modern APKs run. The entire community app scene exists because Light left this door open, and I choose to believe it’s open on purpose.

What’s removed matters more than what’s changed, and the removals fail in a very particular way: the API stays, and answers nothing. There are no Google services, and Android’s plumbing quietly assumes there will be. Some examples I’ve personally lost time to:

  • android.speech.SpeechRecognizer exists, but there’s no provider to bind to. android.speech.tts exists, but no engine is installed. A voice assistant on this phone has to send both directions to the cloud — that’s why LightVoice is built around Whisper and a TTS API rather than anything on-device.
  • SpellCheckerSession accepts your requests and corrections silently never arrive, because nothing is installed to answer them. LightKeyboard bundles its own 63k-word dictionary and a bigram table because that’s the only spell-check that will ever exist here.
  • There’s no push messaging. Anything that needs to be told something has to bring its own transport, which is why my iMessage client talks to a self-hosted server over Tailscale.

The pattern is worth internalizing before you build anything: on LightOS, a dead API doesn’t error. It just never calls you back.

The hardware talks in key events

This is the finding that unlocked the most, so it gets the most space. The wheel, the camera button, and the home button all arrive at whichever app has focus as ordinary KeyEvents. Light patched /system/usr/keylayout/Generic.kl — the keylayout every input device on the phone loads — and you can read the whole story in five lines:

key 19    WHEEL_CCW      # wheel up       (Pixart pat9126ja, was R)
key 20    WHEEL_CW       # wheel down     (Pixart pat9126ja, was T)
key 66    WHEEL_CLICK    # wheel press    (gpio-keys, was F8)
key 80    FOCUS          # camera stage 1 (was NUMPAD_2)
key 27    CAMERA         # camera stage 2 (was RIGHT_BRACKET)

Things I now know that I wish someone had told me:

  • The wheel is a mouse part, not a rotary encoder. It’s a Pixart pat9126ja optical sensor firing one discrete DOWN+UP key pair per notch, roughly 35–60 ms apart. Android’s actual rotary plumbing — AXIS_SCROLL, onRotaryScrollEvent — never sees a thing. Anything wheel-aware has to be built on raw key events, counting notches yourself.
  • WHEEL_CCW, WHEEL_CW and WHEEL_CLICK aren’t AOSP keycodes. They’re Light’s labels, and you resolve them by name at runtime. Hardcode the numeric codes and you’re betting against a firmware update.
  • The camera button is two-stage, like a real shutter: half-press sends FOCUS, full press sends CAMERA. Two keys, two gestures, and the stock camera’s behavior stops being magic.
  • Nothing intercepts any of this in PhoneWindowManager. The brightness ramp and flashlight in Light’s own tools are app-layer code in those tools. In your sideloaded app the keys arrive and nothing listens, which feels like a missing API and is actually an invitation.

The only sanctioned way to hear these keys globally — without having window focus — is an AccessibilityService with flagRequestFilterKeyEvents; the injection route (INJECT_EVENTS) is signature-only and closed to you. That’s what BrightControl is. One design note I’d urge on anyone building similar: declare canRetrieveWindowContent="false" and subscribe to the minimum event types you can. An accessibility service asks for real trust, and the manifest is where you make the limits checkable rather than promised.

Notifications are boringly standard, and that’s a gift

LightOS posts fully standard Android notifications, so NotificationListenerService just works — that’s the entire basis of LightGlance, which draws ambient notification dots on the black panel. No parsing of Light’s internals, no package allowlist, just the platform doing what the documentation says.

The catch is granting the permission: LightOS has no Settings screen for notification access, so the grant is adb-only —

adb shell cmd notification allow_listener <component>

— and this generalizes. A recurring LightOS pattern is that the capability exists but its Settings UI doesn’t, so runtime permissions that would normally show a dialog get granted with pm grant, appops with appops set. The grants survive reboots. Every one of my READMEs with a permissions section is really documenting this same gap.

While I’m here: there is no reachable always-on display. A real AOD is a low-power panel self-refresh mode owned by SystemUI and the panel driver, and a sideloaded app cannot touch it on any Android. If you see “AOD” on a sideloaded LightOS app, it’s the screen genuinely on at minimum brightness, and the honest budget for that is 2–4%/hr. Glance’s default is to wake for eight seconds when something arrives, because that costs approximately nothing and does most of the job.

Networking has one big footgun

Join a Wi-Fi network that has no internet — a camera’s access point, say — and Android keeps routing your traffic over LTE. Every socket you open goes to the wrong network, and it presents as a device that pairs happily and then refuses to talk. The fix is WifiNetworkSpecifier to join plus bindProcessToNetwork to pin your sockets, and as a bonus the specifier path means the system shows the network picker, your app never sees a scan result, and you don’t need the location permission every other camera app demands. BrightImport is built on exactly this, and its CI fails the build if a location permission ever sneaks into the manifest.

Bluetooth: BLE yes, L2CAP no

Standard BLE scanning works fine — LightPods reads AirPods battery levels straight out of Apple’s proximity advertisements with nothing but BLUETOOTH_SCAN. But the richer channel (AAP over L2CAP, where noise control and real ear detection live) is walled off three ways: Android didn’t allow third-party L2CAP sockets on this codebase until a fix that landed in Android 16, the LP3 is Android 14, and the bootloader is locked so the usual workaround can’t install. Knowing which wall you’re behind saves you from debugging the wrong one.

The panel sets the rules

Everything renders grayscale, and designing for it is closer to print than to normal app work. Contrast does the work color would. Patterns replace palettes — when I needed tape labels to be distinguishable in the recorder, the answer was geometry, not hue. Type carries almost everything, and LightOS’s own type and spacing conventions are consistent enough that matching them makes an app feel native with no other effort.

The conventions nobody voted on

The community converged on packaging norms the way small scenes do — quietly, by copying each other. One APK per GitHub release. Tags ending in a number. A stable signing key per app so upgrades install in place; I go one further and pin the certificate’s SHA-256 in the repo with CI failing on drift, because a changed certificate otherwise surfaces only as Failure: Invalid on someone’s phone. My update tracking and the app index lean on all of these, and none of them are written down anywhere official. They’re just what works, repeated until they became load-bearing.

That’s the phone as I currently understand it. Nothing here is a discovery — it’s all sitting in system files, AOSP sources, and other people’s repos. But small platforms live or die on whether the folklore gets written down, and this is me writing mine down.