<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gzl.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gzl.dev/" rel="alternate" type="text/html" /><updated>2026-09-06T22:28:06-04:00</updated><id>https://gzl.dev/feed.xml</id><title type="html">Giovanni Lupo</title><subtitle>Projects, the Light Phone III, and other things I build.</subtitle><author><name>Giovanni Lupo</name></author><entry><title type="html">Nine ways to say yes</title><link href="https://gzl.dev/blog/nine-ways-to-say-yes/" rel="alternate" type="text/html" title="Nine ways to say yes" /><published>2026-08-27T11:45:00-04:00</published><updated>2026-08-27T11:45:00-04:00</updated><id>https://gzl.dev/blog/nine-ways-to-say-yes</id><content type="html" xml:base="https://gzl.dev/blog/nine-ways-to-say-yes/"><![CDATA[<p>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.</p>

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

<p>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.</p>

<h2 id="the-protocol-was-the-easy-part">The protocol was the easy part</h2>

<p>I assumed the reverse engineering would be the hard bit. It wasn’t, because <a href="https://github.com/Th0rgal/open_oura">Th0rgal</a> 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:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>get nonce       2f 01 2b            → 2f 10 2c &lt;15-byte nonce&gt;
authenticate    2f 11 2d &lt;16 bytes&gt; → 2f 02 2e 00   accepted
</code></pre></div></div>

<p>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.</p>

<p>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.</p>

<p>Then I tried it on the phone.</p>

<h2 id="everything-interesting-is-behind-an-encrypted-link">Everything interesting is behind an encrypted link</h2>

<p>Firmware and serial answer cold. Battery, history and the live streams answer <code class="language-plaintext highlighter-rouge">2f 02 2f 01</code> 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.</p>

<p>A bond needs somebody to say yes.</p>

<p>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.</p>

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

<p>It’s <code class="language-plaintext highlighter-rouge">com.android.settings/.bluetooth.BluetoothPairingDialog</code>, and on this phone that fragment builds a null dialog and dies with a <code class="language-plaintext highlighter-rouge">NullPointerException</code> in <code class="language-plaintext highlighter-rouge">DialogFragment.prepareDialog</code>. 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.</p>

<h2 id="nine-routes">Nine routes</h2>

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

<ol>
  <li><strong>Wait for the prompt.</strong> No prompt is drawn, and most BLE bonds have no dialog at all, so I was waiting on an event that doesn’t happen.</li>
  <li><strong>Answer the request myself</strong> with <code class="language-plaintext highlighter-rouge">setPairingConfirmation(true)</code>. It throws. The call wants <code class="language-plaintext highlighter-rouge">BLUETOOTH_PRIVILEGED</code>, which is signature-level and can’t be granted over adb.</li>
  <li><strong>Start the Settings dialog directly.</strong> That’s the crash above.</li>
  <li><strong>Pair by hand in Settings.</strong> AOSP Settings crashes on the pairing screen. LightOS has its own Bluetooth screen, which pairs earbuds happily, and it says “pairing” and then stops.</li>
  <li><strong>Press the notification’s Pair button</strong> 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.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">pm clear com.android.settings</code></strong>, in case a crashing Settings never posted the request in the first place. Reversible, worth ruling out, changed nothing.</li>
  <li><strong>The companion device flow.</strong> <code class="language-plaintext highlighter-rouge">CompanionDeviceManager</code> 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: <code class="language-plaintext highlighter-rouge">createBond</code> 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.</li>
  <li><strong>Ask with the screen off.</strong> This one is real. <code class="language-plaintext highlighter-rouge">BluetoothPairingRequest</code> only starts the dialog activity while the phone is interactive, and asleep it posts the notification instead. Which is step 5.</li>
  <li><strong>Give up on the phone’s UI entirely.</strong></li>
</ol>

<h2 id="the-shell-holds-the-permission">The shell holds the permission</h2>

<p><code class="language-plaintext highlighter-rouge">com.android.shell</code> has <code class="language-plaintext highlighter-rouge">BLUETOOTH_PRIVILEGED</code>, granted and privileged, along with <code class="language-plaintext highlighter-rouge">BLUETOOTH_STACK</code>. 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.</p>

<p>So the pairing helper is a plain <code class="language-plaintext highlighter-rouge">main</code> class living inside my own APK, which is world-readable, run through that shell:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLASSPATH=/data/app/…/base.apk app_process / com.gios.brightoura.helper.Confirm &lt;MAC&gt;
</code></pre></div></div>

<p>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 <code class="language-plaintext highlighter-rouge">app_process</code> took four discoveries, and not one of them throws when it’s missing:</p>

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

<p>Four evenings. Then this came back:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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
</code></pre></div></div>

<p>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.</p>

<h2 id="i-never-asked-why">I never asked why</h2>

<p><code class="language-plaintext highlighter-rouge">ACTION_BOND_STATE_CHANGED</code> carries <code class="language-plaintext highlighter-rouge">EXTRA_REASON</code> — 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 <code class="language-plaintext highlighter-rouge">NONE</code>, and printed “refused after confirming,” which is an honest answer to the wrong question.</p>

<p>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.</p>

<p>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.</p>

<h2 id="what-the-failure-was-worth">What the failure was worth</h2>

<p>Three things came out of it that the success wouldn’t have produced.</p>

<p><strong>A Ring 4 Bluetooth table nobody appears to have published.</strong> 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.</p>

<p><strong>A decoder that can be corrected.</strong> 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.</p>

<p><strong>A rule I keep relearning.</strong> 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.</p>

<p>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.</p>

<h2 id="if-you-want-to-take-it-further">If you want to take it further</h2>

<p>Everything is in <a href="https://github.com/gi-os/BrightOura">BrightOura</a>, MIT, and the <a href="https://github.com/gi-os/BrightOura/blob/main/docs/HANDOFF.md">handoff document</a> 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.</p>

<p>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.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="BrightOura" /><category term="Bluetooth" /><category term="Android" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">Field notes on LightOS</title><link href="https://gzl.dev/blog/field-notes-on-lightos/" rel="alternate" type="text/html" title="Field notes on LightOS" /><published>2026-08-21T11:48:00-04:00</published><updated>2026-08-21T11:48:00-04:00</updated><id>https://gzl.dev/blog/field-notes-on-lightos</id><content type="html" xml:base="https://gzl.dev/blog/field-notes-on-lightos/"><![CDATA[<p>After a few dozen apps and a lot of evenings with <code class="language-plaintext highlighter-rouge">adb</code>, 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.</p>

<h2 id="its-android-14-and-thats-a-feature">It’s Android 14, and that’s a feature</h2>

<p>LightOS is Android 14 underneath, with a locked bootloader. No jailbreak, no unlock, no exotic toolchain — <code class="language-plaintext highlighter-rouge">adb install</code> 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.</p>

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

<ul>
  <li><code class="language-plaintext highlighter-rouge">android.speech.SpeechRecognizer</code> exists, but there’s no provider to bind to. <code class="language-plaintext highlighter-rouge">android.speech.tts</code> exists, but no engine is installed. A voice assistant on this phone has to send both directions to the cloud — that’s why <a href="https://github.com/gi-os/LightVoice">LightVoice</a> is built around Whisper and a TTS API rather than anything on-device.</li>
  <li><code class="language-plaintext highlighter-rouge">SpellCheckerSession</code> accepts your requests and corrections silently never arrive, because nothing is installed to answer them. <a href="https://github.com/gi-os/LightKeyboard">LightKeyboard</a> bundles its own 63k-word dictionary and a bigram table because that’s the only spell-check that will ever exist here.</li>
  <li>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.</li>
</ul>

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

<h2 id="the-hardware-talks-in-key-events">The hardware talks in key events</h2>

<p>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 <code class="language-plaintext highlighter-rouge">KeyEvent</code>s. Light patched <code class="language-plaintext highlighter-rouge">/system/usr/keylayout/Generic.kl</code> — the keylayout every input device on the phone loads — and you can read the whole story in five lines:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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)
</code></pre></div></div>

<p>Things I now know that I wish someone had told me:</p>

<ul>
  <li><strong>The wheel is a mouse part, not a rotary encoder.</strong> 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 — <code class="language-plaintext highlighter-rouge">AXIS_SCROLL</code>, <code class="language-plaintext highlighter-rouge">onRotaryScrollEvent</code> — never sees a thing. Anything wheel-aware has to be built on raw key events, counting notches yourself.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">WHEEL_CCW</code>, <code class="language-plaintext highlighter-rouge">WHEEL_CW</code> and <code class="language-plaintext highlighter-rouge">WHEEL_CLICK</code> aren’t AOSP keycodes.</strong> 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.</li>
  <li><strong>The camera button is two-stage</strong>, like a real shutter: half-press sends <code class="language-plaintext highlighter-rouge">FOCUS</code>, full press sends <code class="language-plaintext highlighter-rouge">CAMERA</code>. Two keys, two gestures, and the stock camera’s behavior stops being magic.</li>
  <li><strong>Nothing intercepts any of this in <code class="language-plaintext highlighter-rouge">PhoneWindowManager</code>.</strong> 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.</li>
</ul>

<p>The only sanctioned way to hear these keys <em>globally</em> — without having window focus — is an <code class="language-plaintext highlighter-rouge">AccessibilityService</code> with <code class="language-plaintext highlighter-rouge">flagRequestFilterKeyEvents</code>; the injection route (<code class="language-plaintext highlighter-rouge">INJECT_EVENTS</code>) is signature-only and closed to you. That’s what <a href="https://github.com/gi-os/BrightControl">BrightControl</a> is. One design note I’d urge on anyone building similar: declare <code class="language-plaintext highlighter-rouge">canRetrieveWindowContent="false"</code> 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.</p>

<h2 id="notifications-are-boringly-standard-and-thats-a-gift">Notifications are boringly standard, and that’s a gift</h2>

<p>LightOS posts fully standard Android notifications, so <code class="language-plaintext highlighter-rouge">NotificationListenerService</code> just works — that’s the entire basis of <a href="https://github.com/gi-os/LightGlance">LightGlance</a>, 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.</p>

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

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb shell cmd notification allow_listener &lt;component&gt;
</code></pre></div></div>

<p>— and this generalizes. <strong>A recurring LightOS pattern is that the capability exists but its Settings UI doesn’t</strong>, so runtime permissions that would normally show a dialog get granted with <code class="language-plaintext highlighter-rouge">pm grant</code>, appops with <code class="language-plaintext highlighter-rouge">appops set</code>. The grants survive reboots. Every one of my READMEs with a permissions section is really documenting this same gap.</p>

<p>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.</p>

<h2 id="networking-has-one-big-footgun">Networking has one big footgun</h2>

<p>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 <code class="language-plaintext highlighter-rouge">WifiNetworkSpecifier</code> to join plus <code class="language-plaintext highlighter-rouge">bindProcessToNetwork</code> 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. <a href="https://github.com/gi-os/BrightImport">BrightImport</a> is built on exactly this, and its CI fails the build if a location permission ever sneaks into the manifest.</p>

<h2 id="bluetooth-ble-yes-l2cap-no">Bluetooth: BLE yes, L2CAP no</h2>

<p>Standard BLE scanning works fine — <a href="https://github.com/gi-os/LightPods">LightPods</a> reads AirPods battery levels straight out of Apple’s proximity advertisements with nothing but <code class="language-plaintext highlighter-rouge">BLUETOOTH_SCAN</code>. 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.</p>

<h2 id="the-panel-sets-the-rules">The panel sets the rules</h2>

<p>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.</p>

<h2 id="the-conventions-nobody-voted-on">The conventions nobody voted on</h2>

<p>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 <code class="language-plaintext highlighter-rouge">Failure: Invalid</code> 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.</p>

<p>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.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="LightOS" /><category term="Android" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">A tape recorder, not a voice recorder</title><link href="https://gzl.dev/blog/a-tape-recorder-not-a-voice-recorder/" rel="alternate" type="text/html" title="A tape recorder, not a voice recorder" /><published>2026-08-21T09:12:00-04:00</published><updated>2026-08-21T09:12:00-04:00</updated><id>https://gzl.dev/blog/a-tape-recorder-not-a-voice-recorder</id><content type="html" xml:base="https://gzl.dev/blog/a-tape-recorder-not-a-voice-recorder/"><![CDATA[<p>The newest app on my shelf is <a href="https://github.com/gi-os/BrightRecorder">BrightRecorder</a>, and the fastest way to describe it is by what it refuses to be. It is not a voice recorder. There is no transcription, no trimming, no waveform editor, no file manager. It records rooms, streets, weather, rain on windows — and the only thing it does with them is let you find them again.</p>

<h2 id="tapes-not-files">Tapes, not files</h2>

<p>Record a moment and it becomes a clip named for where you were and when. Clips live on tapes, and you keep more than one — a tape for a trip, a tape for the apartment, a tape for the year. The machine holds exactly one tape at a time, and you swipe through the rest on a shelf. Each tape gets a name and a pattern on its label, which is what color would be if this panel had any.</p>

<p>Under the hood a tape is a directory of WAV files and nothing more. The clip’s timestamp and place are the filename. Rename the folder, you’ve renamed the tape. Plug the phone into a desktop and the whole collection reads as itself — no database, no export step, no app required to get your own recordings back. I keep coming back to that rule in these apps: the storage format should make the app optional.</p>

<h2 id="the-wheel-finally-has-its-job">The wheel finally has its job</h2>

<p>The part I’m happiest with: winding. Turn the brightness wheel and you wind through a tape, forwards or backwards, and you <em>hear it while you wind</em>, the way a real tape machine chatters while you scrub. Let go and it plays from where you landed.</p>

<p>I’ve put the wheel to work in most of my apps — scrolling, lens ring, brightness — but this is the first time it’s felt less like an adaptation and more like the thing the hardware was born for. A physical wheel wants to shuttle tape. It’s been waiting for this job since the phone shipped.</p>

<p>None of this is a new idea. It’s the oldest audio interface there is, drawn in software. That’s the whole appeal — a tape recorder was already a nearly perfect object, and the only modern thing this phone needed to add was the filename.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="BrightRecorder" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">Why I put an app store on a phone designed to have none</title><link href="https://gzl.dev/blog/an-app-store-for-a-dumbphone/" rel="alternate" type="text/html" title="Why I put an app store on a phone designed to have none" /><published>2026-08-18T21:58:00-04:00</published><updated>2026-08-18T21:58:00-04:00</updated><id>https://gzl.dev/blog/an-app-store-for-a-dumbphone</id><content type="html" xml:base="https://gzl.dev/blog/an-app-store-for-a-dumbphone/"><![CDATA[<p>I am aware of how this sounds. The Light Phone III is a phone built on the idea that you should stop looking at your phone. I built an app store for it.</p>

<p>Let me at least explain how I got here. <a href="/blog/brightmarket-is-out/">The launch post</a> covers what shipped and how to get your own app listed. This is the other half: why it exists at all, what’s holding it up, and the afternoon I broke it.</p>

<h2 id="the-problem-was-never-installing-apps">The problem was never installing apps</h2>

<p>Sideloading works fine on this phone. LightOS is Android underneath, and an APK installs the way an APK installs anywhere. The problem is what happens three weeks later, when you have fifteen sideloaded apps and every one of them updates on its own schedule, on its own GitHub release page, and nothing on the phone knows any of that.</p>

<p><a href="https://github.com/ImranR98/Obtainium">Obtainium</a> solves this, and solves it well. I used it for months. But it’s built for a normal Android phone, and it only knows about the repos you paste into it yourself. There was no answer to the more basic question: how does anyone find out these apps exist at all? For a while the honest answer was “a Reddit thread and word of mouth.”</p>

<p>So BrightMarket does the same update tracking Obtainium does, fits this phone, and adds a catalog on top. Find an app, install it, get told when it needs updating. That’s the whole product.</p>

<h2 id="the-uncomfortable-part">The uncomfortable part</h2>

<p>An app store on a minimalist phone is still an infinite scrolling feed. I couldn’t design my way out of that, so I stopped trying and made browsing optional instead. On first launch the app asks. With Focus mode on, it shows what you have installed and what needs updating, and nothing else. No catalog, no feed, no discovery. A surprising number of people pick that mode, which I choose to read as the userbase keeping me honest.</p>

<h2 id="no-server-on-principle-and-also-on-budget">No server, on principle and also on budget</h2>

<p>There is no backend. The “database” is <a href="https://github.com/gi-os/brightmarket-index">a public GitHub repo</a>: one YAML file per app, and a CI job that rebuilds a single JSON index every hour and serves it from GitHub Pages. Submissions arrive as pull requests against that repo, which means the whole catalog has a public history — you can read every app that was ever added, by whom, and when.</p>

<p>Download counts come from GitHub’s own release statistics, so the “most downloaded” sort costs nothing and tracks nobody. The app never phones home. I couldn’t run analytics if I wanted to, which is the correct amount of temptation to have.</p>

<h2 id="the-part-where-i-got-it-wrong">The part where I got it wrong</h2>

<p>One afternoon in August, the GitHub Releases API started returning empty lists. Not errors — clean HTTP 200 responses with nothing in them. My builder read “no releases” and helpfully dropped those apps from the index. Other people’s apps, gone from the catalog, because of my code and someone else’s outage.</p>

<p>An HTTP error had always kept the previous entry. A <em>successful empty response</em> didn’t, because I’d never imagined one. The rule that came out of it is now written into the repo: an app is never dropped for an answer the API failed to give.</p>

<p>Somewhere along the way this thing became how most Light Phone III owners install community apps. I didn’t plan for that and I try not to think about it too hard. Mostly it means the bar for “good enough” moved, and the empty-response bug is what taught me where it moved to.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="BrightMarket" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">A good fork says thank you</title><link href="https://gzl.dev/blog/a-good-fork-says-thank-you/" rel="alternate" type="text/html" title="A good fork says thank you" /><published>2026-08-14T15:21:00-04:00</published><updated>2026-08-14T15:21:00-04:00</updated><id>https://gzl.dev/blog/a-good-fork-says-thank-you</id><content type="html" xml:base="https://gzl.dev/blog/a-good-fork-says-thank-you/"><![CDATA[<p>A decent amount of what I ship for the Light Phone is other people’s work with my name nearer the top than it deserves. BrightChat is <a href="https://github.com/craigeley/chat">Craig Eley’s chat app</a>. BrightMusic builds on Jonathan Caudill’s phono. BrightNews started as zachattack323’s LightRSS. BrightLibrary is a FastRead fork, and BrightThumb is a port of Thumb-Key. None of these were my ideas. The community around this phone is tiny, the good ideas are unevenly distributed, and forking is how the work compounds.</p>

<p>Which is fine — that’s what open source is for. But forking has an etiquette, I’ve been on both ends of it now, and I have opinions.</p>

<h2 id="keep-their-readme">Keep their README</h2>

<p>When I forked Craig’s chat app, his README didn’t get rewritten — it got moved to the bottom of mine, preserved as he wrote it, under his name. The top of the file says plainly: Craig wrote the app; here’s what this fork adds; nothing here is upstream’s responsibility. That last part matters more than the credit. The worst thing a fork can do to its upstream isn’t stealing glory, it’s leaking bug reports. If my Favorites tab breaks, that issue belongs on my repo, and the README’s job is to make sure it lands there and not in Craig’s inbox.</p>

<h2 id="never-touch-the-applicationid">Never touch the applicationId</h2>

<p>This one’s technical and absolute. On Android, the applicationId is the app’s identity. Change it and you haven’t renamed the app — you’ve created a second app that installs alongside the first, and every user’s data stays trapped in the old one. Several of my apps still carry <code class="language-plaintext highlighter-rouge">light*</code> package names from before a rename, and they always will. The name on the screen is free to change. The identity underneath is not, and no amount of tidiness justifies breaking it. This rule is written into the BrightMarket index with a comment that says, roughly, do not “fix” this.</p>

<h2 id="renaming-is-not-rebranding">Renaming is not rebranding</h2>

<p>The Bright* names exist so the collection reads as a collection — so someone who liked one app can find the others, and so update tracking has a consistent home. The rename is packaging, not authorship. If the name change ever starts implying the work is mine when it isn’t, the README exists to un-imply it, loudly and first.</p>

<h2 id="send-things-back-when-they-fit">Send things back when they fit</h2>

<p>Not everything I add belongs upstream — a Favorites tab tuned to this phone’s wheel probably doesn’t help a general-purpose app. But when a fix is general, it should travel. The fork relationship shouldn’t be a one-way valve.</p>

<p>I don’t think any of this is original wisdom either. It’s mostly what the licenses already ask, plus some manners. But small communities run on the assumption that building on someone’s work is a compliment and not a raid, and that assumption survives exactly as long as forks keep saying where they came from.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="open source" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">My bug reports file themselves</title><link href="https://gzl.dev/blog/my-bug-reports-file-themselves/" rel="alternate" type="text/html" title="My bug reports file themselves" /><published>2026-08-11T18:47:00-04:00</published><updated>2026-08-11T18:47:00-04:00</updated><id>https://gzl.dev/blog/my-bug-reports-file-themselves</id><content type="html" xml:base="https://gzl.dev/blog/my-bug-reports-file-themselves/"><![CDATA[<p>Every app I ship for the Light Phone has the same hidden feature: shake the phone three times and it files a bug report. Not an email, not a form — a GitHub issue, opened by the app itself, with the screen you were on, a table of build and device info, the last crash log if there was one, and a screenshot. If the app crashed outright, it asks on the next launch whether to send what it caught.</p>

<p>I built this because the alternative was worse. Bug reports for phone apps normally arrive as a Reddit comment three weeks later: “the camera thing broke.” Which screen? Which version? Gone. The reporter has moved on, and honestly, fair — describing a bug is work, and nobody owes me work. So the app does the describing. The person just shakes.</p>

<h2 id="the-360-pixel-witness">The 360-pixel witness</h2>

<p>The screenshots come through grayscale and 360 pixels wide, and I expected them to be useless. They’re the most valuable part of the report. A broken layout, a stuck spinner, a date that’s obviously wrong — you can see it instantly, in a way no prose description gets close to. Most reports are diagnosable from the screenshot and the build table alone.</p>

<h2 id="then-i-stopped-being-the-bottleneck">Then I stopped being the bottleneck</h2>

<p>The reports land in one repo, and for a while “triage the queue” was a thing I did with coffee. Now an AI agent does the first pass, every hour. It reads each new report, clones the app’s source fresh, and works out what happened.</p>

<p>The rules I gave it are mostly rules about restraint:</p>

<ul>
  <li><strong>Fix it</strong> only when the cause is <em>identified</em> — a stack trace naming the line, code that visibly does the wrong thing — and the fix is small and contained. A null guard. An off-by-one. A missing state reset.</li>
  <li><strong>Hand it to me</strong> when the cause is a guess, when the fix would change behavior someone might have an opinion about, when it needs the physical phone, or when it touches more than about three files.</li>
  <li>Never reuse a stale checkout, because pushing from an old base silently deletes whatever landed in between, and it compiles perfectly afterward.</li>
  <li>Branch first, always. In these repos a push to main cuts a signed APK that’s on people’s phones within minutes, with no human between a mistake and the device. CI runs the same checks on a branch and publishes nothing.</li>
</ul>

<p>The line I keep coming back to when tuning it: guessing is worse than waiting. A wrong fix ships. A waiting issue just waits.</p>

<h2 id="does-it-work">Does it work?</h2>

<p>For the boring majority of reports — yes, and the boring majority is the point. Null guards and layout constraints get diagnosed, fixed, tested, and released while I’m at work, and the release notes say what changed. The interesting bugs, the ones where the report is really a design question wearing a trench coat, get labeled and handed to me with the agent’s notes attached. That split is the whole value: I only see the reports that actually need a human, and the person who shook their phone gets a fix in hours instead of whenever I next had a free evening.</p>

<p>The plumbing that makes this possible in every app at once — the shake detector, the report overlay, the crash handler — lives in a shared library, which is its own unglamorous story for another post.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="AI" /><category term="tooling" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">Fifteen copies of the same file</title><link href="https://gzl.dev/blog/fifteen-copies-of-the-same-file/" rel="alternate" type="text/html" title="Fifteen copies of the same file" /><published>2026-08-08T20:33:00-04:00</published><updated>2026-08-08T20:33:00-04:00</updated><id>https://gzl.dev/blog/fifteen-copies-of-the-same-file</id><content type="html" xml:base="https://gzl.dev/blog/fifteen-copies-of-the-same-file/"><![CDATA[<p>This is a post about the least interesting code I maintain, which is also the code I’d save first in a fire.</p>

<p>At some point I looked up and had a lot of Light Phone apps. Every one of them needs the same plumbing: handle the hardware keys, drive the scroll wheel, draw text in the phone’s type and color system, run the shake-to-report machinery. And every one of them had its own copy. <code class="language-plaintext highlighter-rouge">LightKeys.kt</code> was byte-identical in fifteen repos. The wheel handling existed in about ten variants across twenty — not because ten variants were needed, but because each copy drifted a little after it was pasted.</p>

<p>You can guess the failure mode. A fix to the wheel meant twenty edits, so the fix got made once, in whichever app I happened to be in, and the other nineteen kept the bug. Multiply by every shared component and the collection was slowly turning into an archaeology site where you could date each app by which bugs it still had.</p>

<h2 id="the-part-that-actually-convinced-me">The part that actually convinced me</h2>

<p>Tidiness alone never gets me to do a refactor this dull. What did it was porting the report module into the older apps by hand — the same feature, wired into ten repos by pattern-matching against a reference implementation. Eight of the ten ended up subtly wrong. Not broken-wrong, CI-catches-it-eventually wrong. Same feature, ten bespoke wirings, eight quiet mistakes.</p>

<p>That’s not a tidiness problem, that’s a correctness problem. Copy-paste isn’t just harder to maintain; it’s a machine for manufacturing bugs that all look slightly different.</p>

<p>So the shared pieces moved into <a href="https://github.com/gi-os/BrightCommon">one library</a>, published to GitHub Packages, and every app pulls it in as a dependency like anything else.</p>

<h2 id="small-design-notes-from-a-small-library">Small design notes from a small library</h2>

<p>A couple of decisions I’d defend:</p>

<p><strong>One call, no partial states.</strong> <code class="language-plaintext highlighter-rouge">LightReport.install(...)</code> sets up reporting <em>and</em> arms the crash handler. There’s no way to end up with an app where shake-to-report works but crash capture doesn’t, because there’s no second call to forget.</p>

<p><strong>Inert by default.</strong> Skip the install call entirely and the whole feature is dead weight — the overlay renders nothing, nothing is queued. An app can adopt the library without adopting every feature in it.</p>

<p><strong>The overlay is a sibling, not a wrapper.</strong> It draws in its own window, so it doesn’t care what layout it’s called from, and adopting it doesn’t mean restructuring your UI tree.</p>

<p>One honest annoyance for anyone tempted to do the same: GitHub Packages requires authentication even for public packages. There’s no anonymous read. Every consumer needs a token with <code class="language-plaintext highlighter-rouge">read:packages</code>, which for a hobby ecosystem is pure friction with no upside I can find.</p>

<p>None of this is novel. Shared libraries are the oldest idea in software, and “don’t repeat yourself” is on the first page of every book. But there’s a difference between knowing the advice and watching eight hand-ports go subtly wrong in one afternoon. Now a wheel fix is one edit and a version bump, and the apps stopped drifting apart. Boring work. Recommend it.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="Kotlin" /><summary type="html"><![CDATA[The least interesting code I maintain is the code I'd save first in a fire. On finally building the shared library.]]></summary></entry><entry><title type="html">BrightMarket is out</title><link href="https://gzl.dev/blog/brightmarket-is-out/" rel="alternate" type="text/html" title="BrightMarket is out" /><published>2026-08-08T12:45:00-04:00</published><updated>2026-08-08T12:45:00-04:00</updated><id>https://gzl.dev/blog/brightmarket-is-out</id><content type="html" xml:base="https://gzl.dev/blog/brightmarket-is-out/"><![CDATA[<p>BrightMarket shipped yesterday. It’s two halves that meet in the middle.</p>

<p><strong>The browsing lives on the desktop.</strong> <a href="https://brightmarket.gzl.dev">brightmarket.gzl.dev</a> is a real catalog — read about a tool, decide if you want it, scan its code with the phone, and it opens in BrightMarket ready to install. Browsing on a big screen, installing on the small one.</p>

<p><strong>The tracking lives on the phone.</strong> The other half watches GitHub releases and tells you when something’s out of date. It’ll track any repo that publishes APKs, including ones that have nothing to do with the catalog, so you give up nothing you already follow. And you don’t rebuild your setup by hand: import your Obtainium export and it takes the lot. Catalog apps become normal entries. Everything else is tracked exactly as it was.</p>

<h2 id="the-choice-at-first-launch">The choice at first launch</h2>

<p>The part I sweated most: a gallery on your phone is a scrollable feed, and a scrollable feed on a Light Phone kind of defeats the point. So the app asks, first thing. Keep browsing on the phone if you want it, or go extra light, and the phone only ever shows what you’ve installed and what needs updating. New apps arrive by scanning a code off the desktop. You can still patch something at 2 a.m. without finding a laptop. You just can’t wander.</p>

<p>The friction is asymmetric on purpose. Turning browsing on is one tap. Turning it back off means scanning a code from the desktop page. Falling off the wagon should be slightly harder than climbing back on. I wrote about <a href="/blog/an-app-store-for-a-dumbphone/">why the app is built this way</a> separately.</p>

<h2 id="whats-in-it">What’s in it</h2>

<p>At launch: the twenty-odd apps I’ve built for my own phone plus my forks of community tools — a camera, an ebook reader, RSS and newsletter readers, a TOTP authenticator, live scores, subway times, an Apple TV remote, a notebook, a couple of games. All free, all open source.</p>

<h2 id="getting-your-own-app-in">Getting your own app in</h2>

<p>If you’ve made something for the LPIII, add it. Open <a href="https://brightmarket.gzl.dev/submit.html">the submit page</a>, sign in with GitHub, pick a repo of yours that has a release, done. The sign-in asks for <code class="language-plaintext highlighter-rouge">read:user</code> and nothing else: it can see which public repos you own and can’t write anywhere.</p>

<p>Everything after that is a script. It checks that the release has an APK attached, reads the applicationId out of the APK itself rather than trusting what the form told it, and opens a pull request. Once that lands, the next index build picks your app up and it shows in the catalog.</p>

<p>Two things worth knowing before you submit. The repo has to publish a real release with the APK attached, not just source. And the applicationId is what the update tracker keys on, so changing it later breaks the update path for everyone who already installed you.</p>

<h2 id="launch-day-honestly">Launch day, honestly</h2>

<p>The best bug reports arrived within hours. Uninstalling wasn’t obvious enough — fixed. Adding a repo required making a QR code when typing a URL would do — added. This is the whole reason to ship: a week of real users beats a month of me squinting at my own UI. If Light eventually ships an official tools store, I genuinely hope it’s better than mine. Until then: <a href="https://brightmarket.gzl.dev/apk">brightmarket.gzl.dev/apk</a> on the phone, and everything else installs from inside.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="BrightMarket" /><summary type="html"><![CDATA[The app store ships: browse on a desktop, install by QR, track updates on the phone, and get your own app into the catalog.]]></summary></entry><entry><title type="html">One column of scores</title><link href="https://gzl.dev/blog/one-column-of-scores/" rel="alternate" type="text/html" title="One column of scores" /><published>2026-08-07T18:40:00-04:00</published><updated>2026-08-07T18:40:00-04:00</updated><id>https://gzl.dev/blog/one-column-of-scores</id><content type="html" xml:base="https://gzl.dev/blog/one-column-of-scores/"><![CDATA[<p>I follow too many teams for a phone with no scores app. LightOS has no sports surface at all, and refreshing ESPN’s mobile site on a 3.9-inch grayscale screen is a punishment I kept choosing anyway. So: <a href="https://github.com/gi-os/BrightSports">BrightSports</a>. One column of scores, grouped by day. Standings, one tab per league. A game page with first pitch, venue, TV listing, and both records. A notification when something happens to a team you follow. That’s the whole app, and the restraint is the feature — on this screen, a second column is a design failure.</p>

<h2 id="twenty-two-leagues-zero-api-keys">Twenty-two leagues, zero API keys</h2>

<p>The part I’m actually pleased with is the data story. The app covers MLB, NFL, NBA, NHL, MLS, five European soccer leagues plus the two UEFA cups, FBS college football, four women’s leagues, four levels of minor league baseball, and Formula 1 — and there is no account, no API key, and nothing to paste in, ever. Four public JSON providers carry all of it:</p>

<p><strong>ESPN’s site API</strong> does the heavy lifting: every major, the European soccer, college football, most of the women’s leagues, and F1. The lovely thing about it is that the scoreboard response shape is identical across team sports, so one parser covers everything. The unlovely thing is standings, which nest differently per league — the fix is to walk the response’s <code class="language-plaintext highlighter-rouge">children</code> tree recursively instead of indexing into it and hoping.</p>

<p><strong>MLB StatsAPI</strong> covers the four minor league levels, selected by <code class="language-plaintext highlighter-rouge">sportId</code> (11 through 14 for Triple-A down to Single-A). ESPN publishes no minor-league scoreboard at all; this is the same feed MiLB.com itself runs on, which is as close to the source as you can stand.</p>

<p><strong>HockeyTech/LeagueStat</strong> carries the PWHL, and the fourth provider fills the last gaps. College football needed one extra trick: ESPN’s feed returns roughly 750 schools, so the app filters it down to the ~140 FBS programs, because nobody following Alabama wants to scroll past every Division III scoreline to find them.</p>

<h2 id="boring-on-purpose">Boring on purpose</h2>

<p>Under the hood this is the least clever app I ship, deliberately. Follows are a <code class="language-plaintext highlighter-rouge">SharedPreferences</code> string set. Caches are plain files. There’s no database, no Room, no annotation processing in the build at all — clone it and it compiles fast and dumb. After a year of building increasingly elaborate plumbing for the other apps, there was real pleasure in writing one where the complete persistence layer is “a set of strings.”</p>

<p>Notifications work the way everything works on a phone with no push services: the app checks the feeds itself on a schedule and posts ordinary local notifications when a followed team’s game changes state. No server of mine in the loop, nothing tracking what you follow. If the phone is off, you find out the score the way people did in 2004, which frankly suits this phone.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="BrightSports" /><summary type="html"><![CDATA[Twenty-two leagues from four keyless public APIs, in one grayscale column. How BrightSports gets scores with no account and no push.]]></summary></entry><entry><title type="html">The MetroCard inside my phone</title><link href="https://gzl.dev/blog/the-metrocard-inside-my-phone/" rel="alternate" type="text/html" title="The MetroCard inside my phone" /><published>2026-08-06T21:35:00-04:00</published><updated>2026-08-06T21:35:00-04:00</updated><id>https://gzl.dev/blog/the-metrocard-inside-my-phone</id><content type="html" xml:base="https://gzl.dev/blog/the-metrocard-inside-my-phone/"><![CDATA[<p>Of everything I gave up moving to this phone, tap-to-pay was the one I actually felt. Not apps, not the camera — the turnstile. Standing at the OMNY reader fishing for a card while the person behind me radiates New York patience. The Light Phone III has NFC hardware in it, and the community keeps asking when it’ll do something; so far, nothing shipped uses it. So I stopped waiting and put a MetroCard inside the phone.</p>

<p>I posted the short version <a href="https://old.reddit.com/r/LightPhone/comments/1vgb3iv/how_to_add_transit_cards_to_your_lightphone/">on Reddit</a>. This is the long one.</p>

<h2 id="a-card-is-packaging">A card is packaging</h2>

<p>The thing to understand about a contactless card is that almost none of it is the card. Inside the plastic there are exactly two functional parts: a chip about the size of a grain of rice, and a loop of wire running around the card’s perimeter. The wire is the antenna, and it’s also the power supply — the card has no battery. The reader radiates a 13.56 MHz field; the loop harvests enough energy from it to wake the chip, and the same coupling carries the conversation. That’s the whole machine. The plastic is packaging, and packaging can go.</p>

<h2 id="the-acetone-part">The acetone part</h2>

<p>Transit cards are laminated PVC. Soak one in 100% acetone overnight — pure hardware-store acetone, not nail polish remover, which has oils in it — and the laminate gives up. The layers soften and peel, and what’s left is the chip with its antenna coil attached, looking like the card’s nervous system with the body dissolved away.</p>

<p><img src="/assets/blog/metrocard-acetone.jpg" alt="An OMNY card dissolving in a bowl of acetone, layers separating, the mag stripe peeling away" />
<em>Overnight in the bowl. The laminate gives up in layers; the mag stripe goes first.</em></p>

<p>Handle it like a cobweb. The coil is fine wire, the bond between coil and chip is the weakest point, and there is no fixing it if it tears. It took me a few tries to end up with a working one. I don’t think the task is genuinely hard; I think I’m clumsy, and the difference matters because you should budget more than one card either way.</p>

<h2 id="the-install">The install</h2>

<p>The chip-and-coil goes in the battery compartment. Two things I learned at actual turnstiles, by failing at them:</p>

<p>The coil wants to be spread to the very edges of the cavity while staying a coil. Loop area is everything — the voltage the chip harvests scales with the area the field passes through, and a scrunched-up coil is a deaf one.</p>

<p>Orientation matters more than it has any right to. Chip black-side down reads most reliably for me. I can’t give you the physics for that one; I can give you the turnstile data.</p>

<p><img src="/assets/blog/metrocard-installed.jpg" alt="The Light Phone III's open battery compartment with the chip and antenna coil held under strips of blue painter's tape, the fine wire spread to the compartment's edges" />
<em>An earlier build, taped in for testing — you can see the coil spread to the cavity’s edges. The one currently in my phone is working too consistently to risk opening up for a photo.</em></p>

<h2 id="the-part-where-the-phone-fights-back">The part where the phone fights back</h2>

<p>First full test: intermittent failures, maybe one read in three. The culprit is the phone itself. The LP3’s NFC front end is a coil too, sitting in the same few cubic centimeters, and even idle it detunes the space around it — two resonant loops that close together pull each other’s resonance off frequency, and a transit chip living on harvested power has no margin for that.</p>

<p>The fix is to shut the phone’s radio up:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adb shell svc nfc disable
</code></pre></div></div>

<p>and <code class="language-plaintext highlighter-rouge">svc nfc enable</code> to undo it. The setting survives reboots. With the phone’s NFC off, my read rate went to effectively always. I’m working on an app to make this toggle less of a ritual, because a hardware mod that requires <code class="language-plaintext highlighter-rouge">adb</code> for daily operation is only half done.</p>

<h2 id="living-with-it">Living with it</h2>

<p>This is the part that makes it practical rather than a stunt: OMNY lets you register a card on their website and attach auto-reload. So the card inside my phone refills itself, and the phone taps through turnstiles like it always should have. My card expires in 2033. When it does, I’ll do it again.</p>

<p>The obvious next question — someone asked it within an hour — is a credit card. It should work identically; it’s the same physics and mostly the same chips. I haven’t tried, for the boring reason that I can’t walk into my bank and ask for five test cards. If you try it, I want to hear how it went. And for the advanced class: one commenter re-wound a card’s antenna around the bare chip with a NanoVNA to verify the resonant frequency, and wears it as a ring. That’s real radio work. Mine is the lazy version, and I’m at peace with that.</p>

<h2 id="caveats-honestly">Caveats, honestly</h2>

<p>Do this to a card you can afford to kill, because your first one probably dies. Acetone wants ventilation and patience. The MTA would presumably rather you didn’t, so consider this documentation, not advice. And if the phone ever ships a real wallet, I’ll be first in line to dissolve nothing at all.</p>]]></content><author><name>Giovanni Lupo</name></author><category term="Light Phone" /><category term="hardware" /><category term="NFC" /><summary type="html"><![CDATA[I dissolved a transit card in acetone and moved its nervous system into the battery compartment. Tap-to-pay is back, sort of.]]></summary></entry></feed>