Even Realities G2 · personal build

Development paper

A chronological engineering account — the ideas, the dead ends, the options rejected and why, and the reversals, including the fixes that shipped and did nothing.

Building a Voice-Driven Texting Companion for Even G2 Smart Glasses

An engineering case study: ideas, dead-ends, and what finally worked


Abstract

This paper documents the development of G2 Texting, a system that lets a user read and reply to phone text messages on Even Realities G2 smart glasses, by voice, with all processing confined to the glasses and the phone — no cloud services, no recurring costs, no external computer at runtime. The system is two coordinated halves: a sideloaded Android "bridge" app that exposes the phone's SMS over a local network API, and an Even Hub plugin that runs on the glasses and consumes it. The build surfaced a series of non-obvious platform constraints — a server engine that can't do TLS, a WebView that rejects self-signed certs, an undocumented list-size ceiling, a manufacturer security layer that blocks developer tooling, an audio subsystem that reroutes touch input, and a messaging platform with no public RCS API — each of which forced a design pivot. Notably, a capability initially (and correctly) documented as impossible via the direct API — sending RCS — proved reachable through the notification subsystem built for Wear OS and Android Auto, after a user question prompted re-examination. That same capability also produced the project's most serious defect — a reply delivered to the wrong recipient — whose analysis is included in full. We record the options considered at each fork, why several were rejected, the packaging work required to remove the development laptop from the runtime path, and the architecture that ultimately shipped. A late reversal is also recorded: months of on-device speech work were largely superseded once the platform's own recogniser proved usable, and the integration then spent several test cycles appearing to succeed while never executing — concealed by a fallback that answered every request with a plausible result. A final arc concerns availability rather than capability: once the system did everything it was meant to, it still could not be relied on, and the reasons were distributed across all three tiers — a foreground-service type with a six-hour daily budget, a retry loop on the glasses that forked exponentially and starved its own requests out of the socket pool, a battery exemption stranded on the previous application id by a rename, and one defect introduced during the investigation itself by applying the standard fix for a problem that turned out not to exist. That last episode is recorded in full, because the correct diagnosis arrived only after measuring on hardware contradicted a mechanism that both the author and an independent review had derived, agreed on, and been wrong about.


Building on this stack yourself? BUILDING-FOR-G2.md is the same material reorganised as a practitioner's guide — symptom-indexed gotchas, toolchain setup, and a packaging checklist. This document is the chronological account of how it was found.


1. Motivation and goal

The premise: messaging on smart glasses should be glanceable to read and spoken to reply — heads up, hands free. Two hard product constraints shaped every decision:

  1. Self-contained. Everything runs on the glasses ↔ phone link. No companion server, no cloud API, no developer laptop in the runtime path.
  2. Truly free. Not "free tier" (which can lapse or rate-limit) — genuinely free, forever.

These two constraints did most of the architectural work: they repeatedly eliminated the easy answer and forced the on-device one.


2. System architecture

┌─────────────────────────────┐    loopback 127.0.0.1 (HTTP + WS, token)   ┌─────────────────────────┐
│  Android bridge (phone)     │ ◀─────────────────────────────────────────▶│  Even Hub plugin (G2)   │
│  - SMS content provider     │                                            │  - thread list / reader │
│  - SmsManager send          │   GET /threads  /thread/:id                │  - RCS inbox            │
│  - Notification listener    │   POST /send    POST /rcs-reply            │  - voice dictation loop │
│    (RCS capture + reply)    │   GET /notifications                       │                         │
│  - Ktor server (CIO)        │   POST /stt   GET /stt/status              │  packaged .ehpk, runs   │
│  - STT: Google on-device,   │   WS  /events                              │  in the Even app's      │
│    Vosk as fallback         │                                            │  WebView on the phone   │
│  - Foreground service       │                                            │                         │
└─────────────────────────────┘                                            └─────────────────────────┘
        ▲                                    BLE ▼
   (survives reboot)                    ┌─────────────────┐
                                        │  G2 glasses     │
                                        │  576×288, 4-bit │
                                        │  tap/swipe, mic │
                                        └─────────────────┘

The plugin executes inside the Even app's WebView on the phone, so from its perspective the bridge is reachable at localhost. This co-location is what makes the "no external server" constraint satisfiable: the two halves share a device — and it is also what permits the bridge to bind loopback-only (§3.11), removing the API from the network entirely.

Nothing in the runtime path is off-device. The development laptop participates only in building and packaging; once the .ehpk is installed, the phone and glasses operate alone, across reboots.

Toolchain (resolved at build time, not pinned from a skeleton): JDK 18, Gradle 8.9, Android Gradle Plugin 8.5.2, Kotlin 1.9.24, Ktor 2.3.12, compile/target SDK 35, minSdk 26; Even Hub SDK 0.0.12, vanilla TypeScript + Vite.


3. Problem-by-problem development log

3.1 The SMS bridge: a non-default companion

Idea. Read/send SMS from a sideloaded app without becoming the default SMS handler, so Google Messages stays in charge and the app is a passive companion.

What worked. Runtime permissions RECEIVE_SMS/READ_SMS/SEND_SMS; content-provider queries against Telephony.Sms for threads and messages; SmsManager for sending; a BroadcastReceiver on SMS_RECEIVED to push live events. The app deliberately declares no ROLE_SMS, posts no message notifications (Google Messages owns those), and only shows the mandatory foreground-service notice. Manifest audit confirmed the app requests exactly the three SMS permissions and nothing role-related.

What didn't (by design limit). Mirroring sent messages back into the system store is blocked for non-default apps on modern Android. We made this best-effort and non-blocking: the SMS genuinely sends, but the app's own read view can't reflect it.

3.2 Transport: the HTTPS dead-end

Idea. Serve the API over HTTPS (self-signed on LAN), per the original spec ("CIO + HTTPS").

What didn't work — and why. The Ktor CIO engine does not support server-side TLS. The sslConnector compiled fine but threw at runtime:

java.lang.UnsupportedOperationException: CIO Engine does not currently support HTTPS

This crashed the app the instant the service started. The spec's "CIO + HTTPS" was simply impossible as written.

What worked. We dropped TLS and served plaintext HTTP on the LAN, token-protected. This turned out to be doubly correct: a WebView rejects self-signed certs anyway, so HTTPS would have blocked the plugin regardless. Lesson: two independent constraints (engine capability + WebView trust policy) pointed at the same answer. If TLS is ever required, it belongs at a reverse proxy, not in CIO.

3.3 Getting onto real hardware (a OnePlus foldable)

Three manufacturer-specific obstacles, none in any spec:

3.4 The Even Hub plugin and the list-rendering saga

This was the longest single investigation.

Wrong tool first. We initially assumed even-terminal would load the plugin. It won't — even-terminal drives an AI coding session from the glasses; it is unrelated to plugin loading. The correct path is evenhub qr pointed at the Vite dev server, scanned from the Even app. (Documented here so it isn't re-discovered.)

The container model. The glasses render containers (list / text / image), not pixels. Pages are built once with createStartUpPageContainer, then replaced with rebuildPageContainer or updated in place with textContainerUpgrade. Input arrives via a single event callback with listEvent / textEvent / sysEvent / audioEvent envelopes. A protobuf quirk bit us repeatedly: zero-valued fields are dropped on the wire, so CLICK_EVENT (0) arrives as undefined and must be coalesced.

The failure ladder. Getting a thread list to render took four distinct hypotheses:

Attempt Result Learning
Text "Loading…" page, then rebuild into a list Glasses stuck on "Loading…" A text→list rebuild fired too fast after create; the second update was dropped.
Build the list as the startup page directly rc=1 (invalid params) Not a race — the list config itself was rejected.
Strip to the SDK README's minimal list shape still rc=1 Not the extra fields either.
Drop in the README's literal hardcoded list (with zOrderIndex) rendered Lists work; the problem was our data + a missing field.

Root causes (two). (1) List containers require zOrderIndex (text containers don't). (2) There is an undocumented size ceiling below the advertised 20×64: 20 items × 64 chars was rejected, 15 × 48 accepted.

What worked. An item/action list model (each row carries its own callback, decoupling layout from index math), fixed at a safe size (≤14 rows × 46 chars) with paging (> More / < Prev) to reach all threads, plus the native selection border. An on-glasses debug line (setDebug) and a parallel DOM "mirror" of the display were the instruments that made this tractable — without them, every hypothesis would have been a blind hardware round-trip.

3.5 Contact names

Trivial once framed: READ_CONTACTS + ContactsContract.PhoneLookup, cached per address, with graceful fallback to the raw number (shortcodes and spammers correctly stay numeric).

3.6 RCS: the messages the SMS provider can't see

The problem. RCS never lands in Telephony.Sms; Google Messages stores it in its own Jibe database. Android exposes no public API to read or send RCS.

Options weighed.

Option Receive Send Verdict
NotificationListenerService ✅ new msgs, full text via RemoteInput (see §3.9) Chosen for both
Messages-Web automation (Playwright) ✅ + history ✅ real RCS Rejected: fragile DOM scraping, ToS gray area, pairing upkeep, needs a running computer — violates the self-contained constraint
Read Google Messages' DB directly Rejected: requires root
Accessibility-service UI scraping ⚠️ partial Rejected: brittle

What worked. A NotificationListenerService that captures Google Messages notifications and extracts the full message text from the MessagingStyle EXTRA_MESSAGES bundles (the shade preview is only visually truncated; the data isn't). Exposed at /notifications, surfaced on the glasses as an "RCS inbox." Notably, adb couldn't enable Notification Access on this ColorOS device — but it registered anyway across a reinstall, and the listener bound successfully.

Two filtering problems found only on real data. A naive "any notification with body text" capture is wrong in two distinct ways, and both were invisible until we dumped dumpsys notification --noredact from the live device:

  1. Status notifications masquerade as messages. The first thing the inbox showed was "Device pairing: Your messages are available on the device you've paired" — a Messages status banner, captured because it had EXTRA_TEXT. Real messages are distinguishable by structure, not content: they carry category=msg, android.template=MessagingStyle, and an android.messages bundle array of {sender, text, time}. Fix: require MessagingStyle (EXTRA_MESSAGES), drop the plain-text fallback entirely, and skip FLAG_GROUP_SUMMARY (an aggregate with null text) and FLAG_ONGOING_EVENT.

  2. Lock-screen redaction silently degrades content. When a listener reads notifications while the device is locked, Android hands back the public version: sender=unknown, text="Sensitive notification content hidden". Fix: drop entries with no real sender or redacted text, and re-scan on ACTION_USER_PRESENT so unlocking refreshes the inbox with full content. (A user can also set lock-screen notifications to "show all content".)

  3. The obvious dedup key is the wrong one. The first dedup guard keyed on (StatusBarNotification.postTime, sender, text) and looked sufficient — it wasn't. postTime is refreshed every time Google Messages re-posts a notification, so each connect-backfill and each unlock re-scan re-admitted the entire inbox under new timestamps. The user saw every conversation two and three times over. The fix is to key on the message's own time field from inside the EXTRA_MESSAGES bundle, which is immutable per message. Verified by forcing the trigger (screen off → on → unlock): 17 messages, 17 unique, zero duplicates across re-scans.

    The same fix enabled a capability improvement: because identity is now per-message rather than per-notification, the listener iterates every message in a bundle instead of only .lastOrNull(). Conversations with several pending messages had been silently dropping all but the newest.

Post-fix, the inbox went from junk-and-redactions to a clean, deduplicated feed of real messages with real senders.

3.7 On-device speech-to-text: three ideas, one survivor

This is where the two product constraints did their heaviest lifting.

The pipeline: audioControl(Glasses) → buffered PCM → POST /stt → Vosk → transcript → confirm → POST /send.

3.8 Voice input: touch events move when the mic is on

The bug. With dictation "listening," single-tap-to-finish did nothing. Root cause: while audio capture is active, touch events are delivered via sysEvent, not textEvent — and the handler only listened for textEvent. Compounded by the zero-drop quirk (tap = CLICK = 0 = undefined).

What worked. Handle the sysEvent path during dictation; add a 15-second auto-stop safety net so a missed gesture never strands the user mid-recording; add a 700 ms debounce so a stray startup event can't stop it instantly; and surface a captured-byte counter on the mirror to distinguish "no audio reached us" from "audio reached us but wasn't recognized."

3.9 RCS sending: the exception that was almost missed

The most consequential correction in the project came from a user question late in the build: "There are ways to get your phone to send a text via share, shortcuts, and so on — why can't those push through the bridge?"

Why share/shortcuts don't work. Every user-facing "send via…" mechanism — ACTION_SEND (share sheet), ACTION_SENDTO with an smsto: URI, direct-share/conversation shortcuts, assistant "send message" intents — has the same shape: they hand a pre-filled draft to Google Messages and stop. A human still taps Send. They relocate the send tap into another app's UI; they don't automate it. Android gates silent programmatic outgoing messaging behind the default-SMS role plus a user action, deliberately, to prevent premium-SMS fraud. The only silent send a non-default app gets is SmsManager — SMS only.

The exception. Investigating that question surfaced a path previously dismissed as impossible: notification RemoteInput reply, the mechanism Wear OS and Android Auto use. Google Messages attaches a Reply action carrying a RemoteInput to each incoming-message notification. An app with Notification Access can populate that RemoteInput and fire the action's PendingIntent; Google Messages then sends the message itself — as RCS when the conversation is RCS, correctly threaded, and mirrored into its own store (which incidentally solves the §3.1 "sent messages don't appear" limitation for those threads).

Path Silent? RCS? Fits glasses?
SmsManager ❌ SMS only
Share / ACTION_SENDTO / shortcuts ❌ needs a tap in Messages ❌ opens the phone
Notification RemoteInput ✅ (live-notification threads only)

Implementation. A ReplyRegistry harvests each captured notification's first free-form RemoteInput action, keyed by StatusBarNotification.key and indexed by the normalized phone number Google Messages exposes in extra_im_notification_participant_normalized_destination. Entries are evicted in onNotificationRemoved (the action dies with its notification). Two endpoints use it: POST /rcs-reply {key, text} replies directly from the RCS inbox, and POST /send now prefers a matching RemoteInput action for the thread's number and falls back to SmsManager when none is live — so the glasses contract is unchanged while the transport silently upgrades. Responses report which transport was used ("rcs" or "sms").

Inherent limits. Reply-only; requires a live notification for that conversation; cannot start new conversations or revive dormant threads. Verified on-device: all six captured messages reported canReply=true.

Lesson. The most valuable architectural discovery came from a user challenging a "that's impossible" claim. "No public API for X" is a statement about the direct API surface — adjacent subsystems built for other clients (here, wearables and automotive) may expose the capability indirectly. Re-examine impossibility claims when someone proposes a mechanism you hadn't considered.

3.10 The most serious defect: a reply delivered to the wrong person

The RemoteInput upgrade in §3.9 introduced the project's only genuinely dangerous bug. The user replied to an SMS thread and the message was delivered to an entirely different conversation. Not a rendering glitch or a dropped event — a real message sent to a real person who was not the intended recipient.

Root cause. POST /send has to decide whether a live notification corresponds to the thread being replied to, which means matching phone numbers across two subsystems that format them differently (+17035551234 vs 7035551234 vs a short code). The original matcher was a bidirectional suffix test:

digits(entryAddress).endsWith(want) || want.endsWith(digits(entryAddress))

with a minimum-length guard on want (the target) but none on the stored address. A short code — a carrier notification, five or six digits — therefore matched any number sharing those trailing digits. Worse, the lookup used lastOrNull, so among multiple matches the most recently posted notification won. A carrier message that arrived moments earlier captured a reply meant for a contact.

Fix. Matching is now strict, and deliberately biased toward refusing to act:

Every miss falls back to SmsManager, which routes by thread id and cannot misroute. The failure mode is therefore reduced from wrong recipient to sent as SMS instead of RCS — a degradation, not a disclosure. The bridge additionally returns the resolved recipient (sentTo), which the glasses display as "Sent (rcs/sms) to ⟨name⟩", converting a silent routing decision into a visible one.

Lessons. Three, and the third is the one that generalizes:

  1. Identity matching is safety-critical code and should be written like it. Phone-number comparison looks like string manipulation; it is actually authorization. Convenience heuristics (endsWith) have no place in it.
  2. Prefer refusing to act over guessing. singleOrNull plus a correct fallback is strictly better than lastOrNull plus a plausible one.
  3. Silent inference needs a visible surface. The bug existed for a full build cycle because nothing ever displayed who a message went to. The system had inferred a recipient and never said so out loud. Any automated decision with real-world consequences should report what it decided, in the user's line of sight.

3.11 Packaging for standalone operation

Everything to this point ran with a laptop in the loop: the plugin was served by a Vite dev server and loaded onto the glasses by QR. Making the system self-sufficient — phone and glasses only — required a distinct class of fixes, unified by a single property: each one fails silently on-device and cannot be caught by any test that runs against a dev server.

Hardening that fell out of packaging. The bearer token is compiled into the bundle, and an .ehpk is extractable — so the token cannot be treated as secret. Rather than pursue a pairing scheme with no good input method on a 576×288 display, the bridge was rebound from 0.0.0.0 to 127.0.0.1. The plugin's WebView runs on the same phone, so nothing is lost; the SMS/RCS API simply stops existing on the network. Confirmed by request: reachable on-device, refused from the LAN. The extractable token stops mattering because there is no longer a remote path to use it on.

A pleasant consequence: no dev/prod configuration switch. Loopback binding appeared to break simulator development, since the simulator runs on the laptop. adb forward tcp:8080 tcp:8080 tunnels it over USB — which means http://localhost:8080 is the correct origin in all three environments (packaged on-device, QR sideload, and simulator). The obvious design — a --dev flag or environment-switched origin — was avoided entirely. Dev and production exercise byte-identical code paths, eliminating the whole category of "worked in dev, silent in production" failures that this section is otherwise about.

3.12 Rebuilding the interface around visible controls

The first working UI hid its actions in gestures: tap meant "back" in one view and "send" in another, and double-tap meant "reply" inside a thread but "exit" at the root. It worked, and it was wrong. The user's objection was precise: overloading double-tap breaks the muscle memory every other app on the device has already trained. A gesture that means different things in different places is not an interface, it is a quiz.

The redesign has one rule: swipe moves the selection, tap activates it, double-tap always goes back. Every action is a labelled row you can see and select. Double-tap is handled in a single branch before any view-specific logic, so it structurally cannot be overloaded again.

"Back" then needed a definition. Once the explicit back buttons were removed as redundant — the user's call, and correct: on a 288px display a redundant control costs a row of content — the question became what double-tap should undo. Leaving the whole view was too coarse: on page 3 of a conversation it discarded two page-turns as well. Back now pops a stack: previous page, previous page, then out to the list, then the system exit dialog at the root. The principle is that back should undo the last thing you did, not the whole visit.

One element resists this and cannot be changed: at the root, double-tap must call shutDownPageContainer(1). That is a store requirement — mode 0 or a custom confirmation is an automatic rejection — and it presents the system's own confirm dialog rather than closing outright.

Redefining back as a stack then made a dedicated [< Home] button worth adding back. It had been removed as redundant, and while back meant "leave the view" it was. Once back began stepping through pages one at a time, a direct jump to the thread list became a genuinely different action rather than a duplicate gesture. The general principle: a control is redundant only while it does exactly what a gesture already does. Changing the gesture's meaning changes which controls are redundant, and it is worth re-deriving that rather than assuming the earlier decision still holds.

Reading order follows the medium, not the archive. Threads were initially rendered chronologically and opened on the last page — technically "newest first", but it made page 1 the oldest content and the page counter run backwards against the reading direction. The messages are now reversed before pagination, so page 1 is the newest and paging forward moves back in time, and (1/N) means what it appears to mean. On a glanceable display the archive order is the wrong default; recency is.

Controls state their own gestures. Because a list cannot be updated in place, a live page number on the button would require a rebuild — and a rebuild resets the selection. The label therefore carries the count and both gestures (▼ Tap Next | ▲ Dbl Prev (of 3)) while the current page is rendered in the text as (1/3). A platform limitation turned into an affordance: the control that pages is also the control that documents how to page.

The button glyphs use the display's supported navigation set (▲△▶▷▼▽◀◁●○■□). These are non-ASCII, and the label sanitizer had been stripping everything outside \x20-\x7E — so the sanitizer now carries an explicit allowlist of the glyphs the built-in font actually renders. Anything outside it is dropped deliberately rather than disappearing mid-label on the device.

Timestamps had to be inline, not stacked. Messages carry a ts, and the conventional rendering — a date/time line above each message — was unaffordable: at ~4 lines per page it would have spent a quarter of the screen per message on metadata. The stamp is instead prefixed on the message's own first line, left-aligned so the column stays scannable, and elided by recency: today shows 3:42p, older shows 7/25 2:12a, and a prior year adds the year. The general move is to spend characters instead of lines — horizontal space is comparatively cheap, vertical space is the scarce resource.

Every button costs reading space, and the exchange rate is brutal. A row is ~40px of a 288px canvas. Three buttons plus the page control leave roughly four lines of text — so adding [< Home] turned a fifteen-message thread from fifteen pages into twenty-four. There is no layout trick to recover it; the only lever is showing less history, which is why the visible tail settled at ten messages. On a display this small, navigation and content are in direct competition for the same pixels, and every control has to justify itself in lines of text surrendered.

First attempt: everything becomes a list. Text containers have no selection model, so buttons must live in a List. The obvious move was to make every view a list and render message text as list rows. This satisfied the requirement and read terribly: rows are capped around 46 characters, so a conversation became a column of chopped fragments, and the 14-row budget was spent on content instead of controls.

The correction came from the user, not from me. I had claimed a page could hold only one container, conflating "exactly one container captures events" with "exactly one container." The documentation is explicit — "at most 4 image containers and 8 other containers per page (mix freely)" — and only the event capture is singular. So the right structure is a split: a List of buttons that captures input, and a Text container beneath it that renders the conversation as actual prose. Three benefits followed immediately: real text rendering instead of fragments; page turns via textContainerUpgrade, which does not rebuild the page and therefore preserves the selection and avoids flicker; and buttons that no longer compete with content for rows.

Two traps on the way:

3.13 Pagination by characters is pagination by the wrong unit

The guidelines say to "pre-paginate text at ~400–500 character boundaries." That advice assumes a full-screen text container, and it silently fails twice.

First, buttons shrink the container: with a two-row button list the text area drops from 288px to roughly 200px, so a 420-character page overflows and the tail is unreachable — the SDK offers "no programmatic scroll position", and swipes are already spent moving the button selection. The user saw a scrollbar they could not operate.

Second, and less obviously, characters do not predict height. A conversation formatted one-message-per-block is short in characters and tall in lines: "a: hi\n\nb: ok" is eleven characters and three lines. A thread of five short messages under-counted its own height by a factor of three and clipped despite being nowhere near the character budget.

The first fix — paginating by rendered lines, with line height and characters-per-line measured from actual screenshots — was directionally right and still wrong twice more:

The final design pre-wraps the entire body into rendered lines — hard-splitting any word longer than the line — and then packs exactly maxLines per page. Every page is exactly full, nothing is unreachable, and because each pre-wrapped line already fits the width the display cannot re-wrap and change the height underneath us. Message boundaries no longer align with page boundaries, which is acceptable precisely because the inline timestamps mark where each message begins.

The same measurement exposed the real constraint: about four lines per page once three buttons are present. That is why the visible history shrank to ten messages, why the blank lines between messages were dropped, and why threads open on the newest page.

3.14 The speech engine we should have used from the start

The on-device Vosk decision (§3.7) held for months and was, in retrospect, an answer to the wrong question. It optimised for "which library can I bundle?" when the operative question was "what does the platform already provide?"

Escalating the wrong axis. The first complaint was a proper-noun failure: "Even Realities" transcribed as "either reality". The instinct was a bigger model, so the 39 MB Vosk model was swapped for the 128 MB lgraph build (7.82% vs 9.85% WER). When that still missed, the next proposal was the full 1.8 GB model — justified, embarrassingly, on the grounds that the phone had 254 GB free. The user rejected it immediately and correctly: free space answers whether something fits, not whether it is reasonable to ask a person to download. Measured properly, that model expands to ~2.9 GB on disk (1.63× ratio, measured on the device) for a 27% relative WER gain. Worse, general WER was never the right metric — a phrase absent from the language model stays absent at any model size. The failure was vocabulary, not acoustics.

What the literature actually said. A structured search (recorded under .planning/2026-07-26-offline-stt-android/) surfaced two things the codebase could never suggest. First, sherpa-onnx implements genuine contextual biasing on-device — an Aho-Corasick automaton boosting user-supplied phrases at decode time, no retraining — at roughly half the WER of Vosk from a smaller model (3.88% vs 7.82%, both LibriSpeech test-clean; 42 MB shippable vs 204 MB). Second, and decisively: Android 13 ships createOnDeviceSpeechRecognizer(), which is Google's own voice-typing engine, on-device, free, and costing zero bytes of app size. The stated goal was "at least as good as Google's voice typing." The platform's answer was to be Google's voice typing.

The blocker, and why it wasn't one. Every OS recogniser is built around the microphone, while this app's audio arrives as PCM frames from the glasses over BLE — the same mismatch that killed the Web Speech API in §3.7. The resolution came from reading the SDK rather than the documentation: javap over android.jar showed EXTRA_AUDIO_SOURCE accepting a ParcelFileDescriptor, alongside encoding, sample-rate and channel-count extras. Handing the recogniser the read end of a pipe and pushing buffered frames down the write end works. The same inspection revealed EXTRA_BIASING_STRINGSnative keyword boosting, the very feature the sherpa migration was being considered for — and EXTRA_ENABLE_FORMATTING, which yields punctuation and capitalisation that no bundled model produces.

Three bugs, and the one that mattered. The integration then appeared to work while being entirely inert:

  1. EXTRA_ENABLE_FORMATTING takes a String ("quality"/"latency"), not a boolean. putExtra(key, true) compiles, type-checks, and is silently discarded — Bundle extras accept any type, so a wrong one is not an error, just an absence.
  2. SpeechRecognizer requires RECORD_AUDIO even when audio is supplied through a pipe and the microphone is never opened. Undeclared, it failed instantly.
  3. The fallback concealed both. /stt was written to prefer Google and fall back to Vosk on any failure — defensible as resilience, disastrous as diagnostics. Google's engine failed on every single call for several test cycles while Vosk answered, and the endpoint returned a plausible transcript each time. The author reported success twice on the strength of Vosk's output, having attributed it to an engine that had never run.

What broke the deadlock was not a better guess but the user's observation that the promised punctuation never appeared. Adding an engine field to the response made the truth immediate: {"text": "...", "engine": "vosk"}. With RECORD_AUDIO granted and the formatting extra corrected, the same audio returned {"text": "Hey, I'm testing even reality's texting on my glasses.", "engine": "google-ondevice"} — capitalised, punctuated, and correctly attributed.

Lessons.


3.15 Three ways to lose the words

Dictation worked, then intermittently didn't: "I might say 'Hello, I'd like you to help me do something' but it only catches 'help me do something'." Sometimes the tail vanished instead. Sometimes the whole sentence arrived intact.

Intermittency across both ends of an utterance suggested one flaky cause. It was three independent ones, each biting under different conditions — which is exactly why no single reproduction case held.

Lost beginnings: the microphone opened after the screen was drawn. The dictation handler awaited its page render before calling audioControl(true). Rendering a page on this platform is a rebuildPageContainer over BLE — hundreds of milliseconds. The user taps Reply, begins speaking on the tap as anyone would, and the mic opens somewhere in the middle of the first clause. The fix inverts the order: capture starts on the tap, and the screen catches up.

That change breaks an assumption elsewhere. Audio frames were being accepted only while view.name === 'dictate', i.e. audio capture was inferred from which screen was drawn. Once the two are deliberately desynchronised, that guard drops the very frames the reordering was meant to save. Capture became its own state (capturing), independent of rendering.

Lost endings, part one: in-flight frames were discarded. stopDictation called audioControl(false) and immediately snapshotted the buffer. Frames the glasses had already transmitted but that had not yet arrived over BLE were simply not in it. The mic is now held 400 ms past the tap — a "Done" tap reliably lands while the final word is still being spoken — and frames are drained for a further 350 ms before the buffer is read.

Lost endings, part two: the recogniser ended the sentence itself. This was the non-obvious one, and it lived on the other side of the system. The diagnosis below is correct and the remedy is wrong — it shipped, did nothing, and §3.18 is what actually fixed it. It is left standing because the way it failed is the more useful half of the story. SpeechRecognizer is built for a live microphone, where silence means the speaker has stopped; its endpointer finalises the result at a pause and discards everything after. But this audio is not live — it is a complete recording the user already ended by tapping Done, delivered through a pipe whose close is the end-of-audio signal. A mid-sentence breath was terminating recognition. The silence thresholds are now pinned to 60 seconds:

putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 60_000)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 60_000)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 60_000)

The general shape: an API tuned for one input model behaves subtly wrongly under another. Piping recorded audio into a live-microphone recogniser works, and the defaults are still the live-microphone defaults. That much held up. What did not is the assumption that the knobs the API exposes for those defaults have any effect — see §3.18.


3.16 Spending someone else's storage

With the platform recogniser carrying every request (§3.14), the bundled Vosk model existed only for devices that lack one. The question was whether to delete it. Answering it required first establishing who those devices actually are: Android below 12 (the API is 31+, as is the EXTRA_AUDIO_SOURCE pipe), devices without Google Mobile Services (post-2019 Huawei, de-Googled ROMs, most Chinese-market builds), and — the common case — devices where the offline language pack was simply never downloaded, which reports as unavailable with no explanation. The fallback earns its place for the third group more than the first two.

Keeping it exposed a worse problem than its size. The model was fetched automatically, the first time the bridge started on a device that needed it: 125 MB of someone's data and 204 MB of their storage, spent without being asked. Framed as a caching decision it looks reasonable; framed as what it is — an app deciding to spend a stranger's mobile data — it isn't. prepare() gained an allowDownload flag that is false everywhere except an explicit user action.

A correction worth recording. The change was proposed to me, and by me, as taking the app "from ~250 MB to ~10 MB". That number was wrong. The APK was 22 MB; the 204 MB was the model, which was already a runtime download rather than a bundled asset. Two different quantities — install size and on-disk footprint — had been added together into a figure that described neither. The honest result is smaller and split: the APK went to 15 MB by dropping the x86/x86_64 builds of libvosk.so (a bridge to a pair of glasses does not run on an emulator), and the 204 MB became opt-in rather than automatic. A wrong number in the author's own favour is worth more scrutiny than a wrong number against.

Telling the user without nagging them. On nearly every phone there is nothing to decide, so a launch-time modal would be noise for the many to inform the few. The setup row instead states what speech uses on this device and opens, on demand, a sheet with the specific situation: already handled by the phone; installed as a backup; downloading; failed; or needed and not present, with the cost stated before the download begins. A device that carries the model but doesn't need it — the upgrade path from the old auto-download — is told so and offered the space back.

Two implementation notes. First, the glasses previously reported every unavailable-speech condition as "still loading", which is true for a few seconds and misleading forever after; a not-installed state now produces "Speech is not set up. Open G2 Texting on your phone." Second, the explanation sheet initially rendered with Material's default purple buttons on a light grey tray, against an otherwise black-and-phosphor app — caught in a screenshot, not by reading the code. The cause was a builder mismatch: androidx.appcompat's AlertDialog silently ignores Material 3 surface and shape tokens, so the theme applied to the text but not the container.


3.17 Attribution: who said what

Two related complaints arrived together, and both turned out to be the same underlying mistake — inferring identity from the wrong field.

Group chats appeared as unrelated strangers. Google Messages posts a separate notification per speaker, so a six-person group filled the inbox with six entries that looked like six private conversations. The grouping key was in the payload the whole time. Dumping a live notification settled it in one pass:

android.conversationTitle = "Alex Rivera, Sam Chen, Jordan Ellis, Robin Vance, ..."
android.isGroupConversation = true

EXTRA_TITLE is unusable for this — Google Messages sets it to "A, B, C: latest sender", which changes as people speak. EXTRA_CONVERSATION_TITLE is stable and is the group's identity. The inbox now groups on it, one row per conversation, matching the SMS thread list. The raw title is 70+ characters against a 46-character row, so display shortens it to Alex, Sam +4 — otherwise the name consumes the row and the message has nowhere to go.

Your own replies wore the other person's name. MessagingStyle marks the user's own messages by omitting the sender, and the code filled that absence with the notification title — which is the other participant. Confirmed directly against the device:

[1] Bundle[{text=Thanks!, time=1785084383352}]                     <- no sender: mine
[2] Bundle[{sender=Alexandra Fitzgerald, text=Can you grab milk too?}] <- theirs

Own messages now read "You". One ordering detail matters: a lock-screen-redacted message also has no sender, so the redaction filter has to run first — otherwise every redacted message would be misattributed to the user.

The bug underneath both. Verifying the fix against the live endpoint showed incoming missing from the JSON entirely. kotlinx.serialization omits any property equal to its default, so incoming = true never went over the wire and would have arrived in TypeScript as undefined — falsy — labelling everyone's messages as the user's own. This is the protobuf zero-value drop from §3.4 in a second, unrelated serializer, and it would have shipped: the feature it breaks is the one the surrounding change was adding, so the change "working" and the change being correct look identical from the outside. encodeDefaults = true is now set on the JSON configuration.

The general form is worth naming, because it has now cost this project twice: a wire format that omits defaults makes "false" and "absent" indistinguishable, and every language's default for a missing boolean is false. Any field whose meaning depends on being true by default is a latent bug in that arrangement.

A rendering postscript. With attribution correct, following a conversation was still hard. Direction had been carried by a lone > and a two-space indent — adequate in a mockup, useless in practice, because at 42 characters most messages wrap and every continuation line drops the marker. A three-line message rendered as three unattributed lines. Naming both sides on every line fixed it, using first names only (a full "Alexandra Fitzgerald 💛" costs a third of the line, and inside a conversation there is nobody confuse them with):

7/26 3:05p Alexandra: Can you grab milk
too? Thanks!! I forgot it
7/26 3:06p You: yep, got it

3.18 The same bug again, diagnosed properly

The §3.15 fix shipped and the symptom came straight back, in the user's words: "The message went through but the pause caused the message to only catch the first part."

Three rounds had now been spent on this. The first two had something in common worth naming: both diagnosed by reasoning about the API and then shipped without ever pushing audio through the endpoint. /stt/status had been checked and reported healthy — but it does not touch the recognition path at all, so it was confirming nothing.

Building a fixture instead of using the user as one. The obvious next step was another round trip through the glasses. Instead, macOS's say produced the exact failing input as a file, converted to the same raw format the glasses send:

say -o a.aiff "Just test a new reply."
say -o b.aiff "With a pause, just to see."
afconvert -f WAVE -d LEI16@16000 -c 1 a.aiff a.wav
tail -c +45 a.wav > a.pcm              # strip the header -> raw s16le 16 kHz mono
python3 -c "open('pause.pcm','wb').write(open('a.pcm','rb').read()
             + b'\x00\x00'*24000 + open('b.pcm','rb').read())"   # 1.5 s gap

Posted straight to /stt, this reproduced the failure on the first attempt — a 4.8 second clip returning "Just to?" — and made every subsequent experiment a one-second loop with no hardware, no glasses, and no user in it. Building this in round one would have saved the other two.

Instrumenting instead of theorising. Four plausible explanations were available and there was no way to choose between them by inspection, so the recogniser's callback sequence was recorded and exposed on /stt/status. One request settled it:

ready begin end begin end partial×9 seg endseg | segs=1

The recogniser detects both speech regions — begin end twice — then emits a single segment and ends the session. That single line eliminated three of the four hypotheses and pointed at something none of them had considered.

Three separate causes, all of them upstream of anything the endpointer thresholds could reach:

1. The audio was arriving about a hundred times faster than it was spoken. The whole recording was written into the pipe as fast as the pipe would take it. The endpointer is a time-based state machine built for a live microphone — at that rate its timers are meaningless, and it drops entire segments. Audio is now paced at 4x real time. The constant was measured rather than chosen:

Pace Result
1x–16x Just test a new reply With a pause just to see.
32x Just test a new reply. — everything after the pause silently gone

4x keeps a 4–8x margin and costs about a second on a five-second reply. The failure it guards against is losing half of what someone said with no sign it happened.

2. Every clip lost its final word — even with no pause in it. With pacing fixed, "Just test a new reply." still came back as "Just test a new?". EOF on the pipe is not the "speaker stopped" signal the recogniser needs to finalise; 700 ms of trailing silence is. This defect had been present since the very first version and had stayed invisible, because a missing last word reads as ordinary transcription error rather than as a systematic fault.

3. Recognition ended at the first pause. The mechanism that actually prevents this is EXTRA_SEGMENTED_SESSION, keyed to the audio source:

putExtra(RecognizerIntent.EXTRA_SEGMENTED_SESSION, RecognizerIntent.EXTRA_AUDIO_SOURCE)

The recogniser then segments at pauses however it likes but keeps going until the source is exhausted, delivering each piece to onSegmentResults and signalling completion with onEndOfSegmentedSession. The segments are stitched back together. This is the API designed for precisely this situation, and it was found the same way the EXTRA_AUDIO_SOURCE question was settled in §3.14 — javap over android.jar, which showed both the extra and the two default interface methods that accompany it.

Two smaller defects fell out of the same pass. ERROR_NO_MATCH on a trailing silent segment was marking the entire run failed and discarding words that had already been recognised. And EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS = 60_000, introduced by the §3.15 attempt, would on any device that honoured it have prevented recognition from ever completing inside the 20-second timeout — a latent bug added while fixing a different one, and harmless only because the extra was ignored.

The reporting defect underneath. The user's actual error message was "Speech is not set up. Open G2 Texting on your phone." Speech was set up. The §3.16 work had made any null from the platform recogniser report as a missing engine, so an unrecognised phrase sent the user to their phone to fix nothing. Four causes now report separately — no audio captured, engine failed, engine absent, engine loading — and the error screen states how much audio was captured, which is the one number that separates a dead microphone from a failed transcription.

Final state, verified end-to-end through the real endpoint:

a       -> Just test a new reply.
pause   -> Just test a new reply With a pause just to see.
three   -> Just test a new reply With a pause just to see. Tell them I
           will be about 20 minutes late.
silence -> [failed: no match]
empty   -> [no-audio]

3.19 Two feeds, one list

The RCS work of §3.6 and §3.17 left the app with two screens: an SMS thread list and an RCS inbox. Technically defensible — they came from different sources with different identifiers — and wrong as a product. The user's framing was the whole brief:

"Text message apps are a unified list of messages in order of time sent. Creating two separate views that aren't consolidated causes user confusion and additional friction when trying to scan. It's unfamiliar."

The transport is an implementation detail of the network. It had been promoted to a category the reader has to hold in their head.

Measuring before designing. Two facts, taken from the running system rather than assumed, set the shape of everything after. First, RCS lived in an in-memory list that died with the service, which is why the inbox showed 14 messages against 50 SMS threads — persistence was not an enhancement, it was the only way RCS survived a restart. Second, the notification listener watches all of Google Messages, and Google Messages posts notifications for SMS too: two short-code senders were confirmed to have both a live notification and a provider thread. The same message was already being ingested twice; it was invisible only because the two feeds were rendered in two places that never met. Merging them without a dedup rule would have doubled every SMS with a live notification on day one.

The literature pass returned a negative result, and it was the most useful thing in it. Thirty sources across arxiv, open-source implementations and platform source (§7 of the planning directory). Nobody has built this. Fossify Messages, QKSMS/QUIK, SMS Backup+ and KDE Connect contain zero \brcs\b matches in code — verified by grep, not impression. KDE Connect is the sharpest case: it has both capture paths, mature and maintained, and joins neither. It relays live and stores nothing. "Store nothing" is how the field avoids this problem, and it was unavailable here because the glasses need history.

Three findings changed the design before any code was written:

Making the dangerous state unrepresentable. The identity question — is this RCS conversation the same person as this SMS thread? — has two failure modes, and they are not symmetric. A split shows one contact as two conversations: visible, annoying, harmless. A wrong merge puts two people in one thread: invisible, tidy-looking, and it is how a reply reaches the wrong person. This project had already shipped that exact failure once (§3.10). The user's rule was immediate: "Split when you are not sure. Merge if you are sure."

The implementation does not check that rule, it makes violating it impossible. Confident and unconfident identities are keyed into separate namespacestel: (10+ digits, last ten), short: (short codes, exact), group:, name: — so an uncertain key cannot collide with a certain one by construction. A group chat keyed on its title can never absorb a phone number's thread, because the strings cannot be equal.

Whether that was even possible turned on a fact worth checking rather than assuming, and a notification dump settled it: 1:1 conversations carry extra_im_notification_participant_normalized_destination — a real E.164 number, or the raw digits for a short code. Group conversations carry none. So the confident case is confident because the platform hands over an address, and the uncertain case is uncertain for a structural reason rather than a heuristic one.

Truncation bounds the matching rule exactly. Cross-source matching needs to link a notification copy to a provider copy of the same message, and the notification copy may be shorter. The literature's contribution was to name the right primitive: containment is asymmetric, and a truncated 40-character record scores ~0.1 Jaccard against its own 400-character original while scoring containment 1.0. Edit distance, cosine and Jaccard — the obvious choices — all fail exactly the pairs that most need matching.

But naive containment has a fatal case in chat: "ok thanks".startsWith("ok"), so an "ok" followed by an "ok thanks" would merge and swallow a real message. The platform supplies the guard. Notification.safeCharSequence hard-truncates at MAX_CHARSEQUENCE_LENGTH = 1024, so a notification copy is a prefix only when it is exactly 1024 characters long. Prefix matching is permitted at that length and nowhere else; everything shorter must match exactly. The false-positive class disappears, and the rule is a platform constant rather than a threshold someone tuned.

Result, verified on device: one contact's January SMS history and that day's RCS now read as a single conversation — 60 messages spanning 01/31 to 07/26, previously two screens with no relationship between them.


3.20 The stance that deleted a hypothesis

The research recommended treating gaps as first-class entities. A notification posted and dismissed while the listener was dead is permanently unrecoverable — not in onNotificationPosted (not connected), not in getActiveNotifications() (no longer active), not in notification history (system-only API) — and listeners are deliberately bound BIND_NOT_PERCEPTIBLE so the system can kill them under memory pressure. A store presenting itself as "a consistent replica" would silently lie. So: record the outage, render a gap marker, be honest.

The user rejected it outright:

"What is missed is just missed and hopefully will be filled with another notification whenever it comes in and gets updated. I'm just trying to get what I can. What I can't, I never would have been able to get anyway, so it's a cup half full kind of build. The user still has their phone if they need to see everything."

This is the correct call, and it is worth being precise about why. The glasses are a glanceable companion; the phone is the system of record. Gap markers would spend scarce pixels — four lines per page — on accounting for something inherent and unfixable, and they would make an honest limitation read as brokenness. The store became a best-effort accumulator: rows added when seen, upgraded when a better copy arrives, never asserted to be complete. A hole looks like a quiet conversation.

The stance deleted an entire hypothesis and simplified the schema — no gap entities, no completeness reasoning, no supersession that has to be provably correct rather than better-on-average.

And the intuition behind it turned out to be better supported than the research had credited. Every MessagingStyle notification carries a slice of its conversation's backlog, up to MAXIMUM_RETAINED_MESSAGES = 25, with seven observed in a single live bundle. So an outage shorter than 25 messages in a conversation self-heals on that conversation's next message, with no backfill mechanism at all. "Hopefully will be filled with another notification" is not hope; it is a mechanism, and it was already built.

What survived from the rejected hypothesis is only the half that captures more: re-scan on every onListenerConnected(), ingest every message in each bundle rather than the newest, and fix the rebind path — requestRebind() is a no-op after a process kill, since it early-returns unless the component was explicitly unbound first. Widespread folklore has that backwards.


3.21 An invisible byte, and a fix reported that never happened

The Top 6 feature — six chosen conversations, one tap from dictation — stores an ordered list of conversation keys. Keys, not row ids: the store is an accumulator that may be rebuilt, and a rebuild must never silently re-point someone's slot at a different person. Same asymmetry as §3.19, same reasoning.

An ordered list in one preference string needs a separator, and the separator has to be a character that cannot appear in a key. Reviewing the file after writing it, the line read:

private const val SEP = ""   // conversation keys can contain ':' but never this

An empty separator is a bug — joining keys with nothing makes them unrecoverable. A replacement was written to change it to "\n", the edit was applied, and the fix was reported as done.

The edit had silently matched nothing, and the original was never an empty string. Reading the stored preference back off the device is what exposed it:

keys">tel:5550101234&#1;tel:5550105678&#1;&#1;&#1;&#1;

&#1; is U+0001. The string literal had contained an invisible control character all along, which is why it looked empty in every reading of the source, and why the search-and-replace — keyed on the visible text — matched nothing and failed silently.

Three things compounded, and none of them were the control character:

  1. An invisible character inside a string literal is unreviewable. It cannot be seen, so it cannot be checked, and every reading of that line was wrong in the same way.
  2. A search-and-replace that matches nothing reports success. It is not an error to replace zero occurrences, so the tooling gave no signal.
  3. The fix was verified by re-reading the diff — which confirms intent and never behaviour. The identical mistake as §3.18, in a third form.

The value was functionally correct by accident: U+0001 is a better separator than the newline that was intended, since a control character genuinely cannot appear in a phone number or a display name. The line is now "\u0001" written as an explicit escape, with a comment saying why, and the file was checked for stray control characters (zero). The bug was harmless; the process failure was not.

One more hazard from the same feature, worth recording because it belongs to a class this project keeps meeting. The picker filters a list of names as the user types, while a parallel list holds the conversation keys. ArrayAdapter's built-in Filter reports positions in its own filtered space — so position 0 in a filtered list is not element 0 of the underlying data. Using it would have assigned the wrong person to a slot: the wrong-recipient bug of §3.10 wearing a third costume. Filtering both lists together, by hand, is three more lines and removes the class entirely.


3.22 A panel that says who someone is — and the 218 MB that did not earn its place

The goal, in the user's words: read only the messages and from them say who this person is, what they are like, what would be nice to say back. No name, no number, no contact record — the thread and nothing else.

The first version was a small local model answering all of it. Every answer was fluent and none of them was information:

asked a 350M model answered
who is this person "This person is the other one"
their disposition "optimistic and enthusiastic" / "relaxed and enjoying" / "calm and content"
attachment style three interchangeable answers for three very different relationships
what they most recently want "they most recently asked for or want to do this" — on a thread whose last message was a plain, direct question

The disposition answers are the instructive ones. They are not wrong. They are constant — they fit any friendly thread, which means they carry no information while looking like they do. And the last row is the sharpest: asked to extract something that was sitting in the text verbatim, the model paraphrased the question back.

So the panel was split by what each half is actually good at:

Then that one remaining field was tested properly, on the user's own threads, against three Q4_0 GGUFs: IBM Granite 4.0 350M (Apache-2.0, 218 MB), LFM2.5-350M (209 MB, licence revenue-gated), and Qwen3-0.6B (Apache-2.0, 364 MB). Three prompt shapes, eight threads:

prompt shape what came back
prose summary, with an "if you can't tell, say so" escape hatch Granite took the escape hatch on 8 of 8 threads. An abstention that costs nothing is the easiest token to predict.
prose summary, escape hatch removed roleplay ("I'm doing well, thanks for the information"), whole messages quoted back verbatim, and — worst — one thread's panel containing a different contact's name
two worked examples, answer capped at one line fluent and well-shaped, but Qwen3 answered two unrelated threads with "groceries and errands" — lifted straight out of the prompt's own example
same, plus every content word required to appear in the thread fabrication gone (8/8 grounded for Qwen3), but the surviving answers were keyword soup: "750, image", "image, success", "cherry, baby, score, link, place". Two of eight read like something a person would want.

The grounding check is worth keeping as a technique — requiring that every content word appear in the source, matched on stems, is cheap and it killed the fabrications outright. What it could not do is make a 350M model interesting. Both fabrication and vacuity were eliminated only by eliminating the content.

The measured verdict: the model does not earn its download. Best case two useful lines in eight, against three counted fields that were correct on every thread and cost nothing. On a panel whose entire value is that it is never wrong, a field that is right half the time is worse than an empty one, because nothing on the display distinguishes the halves.

Two second-order findings from the same work:

The about field remains in the schema, null and documented, because the question "would a bigger model earn it" is worth keeping open. The panel ships without one.


3.23 The verdict overturned: two bugs were mine, and the 1B earns it

§3.22's conclusion did not survive the user pushing back on it — "all the text messages at its disposal and it can't come up with anything?" — and the push-back was correct. Re-testing found that the measurement, not the model, was broken, in two separate ways:

  1. llama.cpp's Metal path miscomputes Granite 4.0. Every answer from the 1B on the Mac came back as an unbroken run of @ characters, which I had scored as model failure. Granite 4.0 is a hybrid Mamba architecture; the same build's CPU path is correct. So the only model in the size class that could have passed was the one being silently corrupted by the eval rig. (The 350M results were computed correctly and really were that bad — its answers are unchanged on CPU.)
  2. The grounding check was aimed at the wrong words. Requiring every content word to appear in the thread rejects "casual" and "parenting" — correct abstractions that no single message contains verbatim. Narrowed to concrete tokens only (capitalised words and numbers, matched on stems), it still kills invented names, places and dates — the failures that matter — without executing every correct answer.

With both fixed, Granite 4.0 1B (Q4_0, 975 MB, Apache-2.0) on CPU, asked two near-extractive questions per thread over a cleaned transcript, produced usable panel lines on most threads: the city a thread kept returning to and the flights being booked to it; a family thread's event arrangements; what a planning thread needed help with. Where its answer failed the grounding check the field ships empty — on one group thread both fields came back blank, which is the design working, not failing.

Getting the same result on the phone surfaced a third bug worth recording, because it presents as "phones are too slow for this" and is nothing of the kind: AGP's debug variant configures CMake with CMAKE_BUILD_TYPE=Debug, which compiles ggml at -O0. First run on device: 489% CPU, ten minutes, no output. Forcing CMAKE_BUILD_TYPE=Release plus -march=armv8.2-a+dotprod+i8mm turned the same job into 15–30 seconds per conversation — a ~20× swing available from two build flags. Note the failed intermediate attempt: appending -O3 -march=… to CMAKE_CXX_FLAGS as one string made clang read it as a single argument (invalid integral value '3 -march=…'); add_compile_options() takes flags as separate tokens and is the correct mechanism.

Shipped shape: the model is a 975 MB opt-in download, explained and priced in the app the same way the Vosk fallback is, never fetched or loaded as a side effect. The background worker fills one conversation per pass, only after the counted fields have drained, and closes the model between passes — measured 2.24 GB RSS during a pass, back to 180 MB after. The two model lines lead the panel; the counted lines keep working without them.

The lesson §3.22 should have stated and didn't: before declaring a model class unfit, separate the model's failures from the harness's. Three of the four damning observations — @ garbage, "none" on every thread, correct answers rejected as ungrounded — were the eval rig. The model's own genuine failure mode at 350M (fluent, constant, information-free answers) was real, but it was the smallest of the problems, and it disappeared at 1B.


3.24 From two phrases to a profile: one prefill, four sections, and a freshness clock

The 0.6.1 output was honest and thin — a city, and the flights to it — and its one user rejected it on sight: baby content. The fix was not a bigger model; it was asking for more in a shape the same model could deliver and the checks could still police.

One generation, not four questions. The phone's seconds go to prefill — the ~500-token transcript costs the same whether the model is asked for seven words or two hundred. So the two questions became one prompt requesting four labelled sections (Topics / Now / Lately / Vibe), with the decode budget raised from 28 tokens to 200. Cost per conversation moved from ~15–30 s to ~40 s, for roughly ten times the content. Asking the four questions separately would have quadrupled the prefill instead.

Ground per section, not per answer. Each heading is parsed out and passes or fails the grounding check alone, so one invented line costs itself rather than the profile. Two refinements the first profile run forced:

The native layer's stop-at-first-newline rule — correct for the one-phrase contract — had to go with it; multi-line output is now the caller's to parse.

A profile is not an artefact, it is a cache with a clock. The 0.6.1 worker computed each conversation's model half once and never again — about IS NULL was the queue condition, so a filled row could never re-enter. The user's requirement was the opposite: "I expect to get updates as new messages come in." The row now stores bio_at, the time the model read the thread (captured before the transcript is built, so a message landing mid-pass re-queues it), and the queue condition is bio_at IS NULL OR bio_at < conversation.last_at. Every new message makes its conversation's profile stale automatically; the worker regenerates most-recent-first, one per pass, still behind the counted fields in priority.

The bio page joined the design language. Full-width prose under a Back button became the same rail the thread page uses — Back, Next, Prev stacked in the left fifth, no glyphs — with the whole profile in one bordered panel, paged. The panel sits in the last 60% of the display, exactly as sketched. The first build ran it from the rail to the right edge instead, on the instinct that a fifth of a 576-pixel display left empty was the whitespace bug of §3.22 returning; the designer reaffirmed the 60%, and the reasoning differs in kind: §3.22's gap was a column mis-measured against a proportional font — content shrunk by accident — where this fifth separates controls from content on a display sitting an inch from an eye. Breathing room chosen is not whitespace leaked. At 32 worst-case characters the panel still holds a third more than the thread page's, and it pages.


3.25 The row that cannot grow

"Person name on top and message on bottom — two lines per list item." A \n inside a list item's label does render a second line of glyphs, which makes the feature look one sanitizer change away. It is not, and the proof took two screenshots and one equation.

With the second line rendering but colliding into the next item, the obvious theory was that an item's slot is the container's height divided by the item count — size the list to two text lines per item and the slots grow. Sized exactly so, the collision did not move. The first item's offset from the top of the container did: 27px in one layout, 44px in another. Two configurations, two unknowns: if items advance a fixed A and the group is centred, the offset is (H − N·A) / 2 — and both screenshots solve to A = 40, exactly. Items advance a fixed 40 pixels and the group centres in whatever container it is given. There is no itemHeight, so on this platform a taller selectable row does not exist.

The faithful implementation spends two real items per conversation — the name, then its message indented beneath, both carrying the same open-the-thread action, so the selection border landing on either line reads as selecting the conversation. Capacity arithmetic: six 40px slots under the clock, one to Top 6 on the first page, one to the pager, two per conversation. The pager gained a count (▼ Older (2/60)) and lost its counterpart — going newer is double-tap, which walks back a page before it exits, the same contract the document pager already used.

The dictation and confirm screens moved to the same rail-and-content layout as the bio page (actions in the left fifth, content in the remaining 80%), which retired the last of the glyph-prefixed button labels — at eight characters of rail, was a quarter of the word. The rail also retired the on-screen gesture hint ("tap = Done, double-tap = Cancel"): the fallback behaviour it described still holds while the mic owns the input stream, but two labelled buttons already say it, and a line that restates visible controls is chrome.

Coda: the paired list lasted one session. It rendered exactly as sketched and was reverted by its user the same day, for arithmetic no rendering could beat: one-line rows put eleven conversations on a page, pairs put two. The platform discovery stands — the fixed 40px advance is in the guide — but the feature it enabled lost to density, which is its own lesson: on 288 pixels, information per swipe beats typography.


3.26 The unread dot: one line of UI, three ways to be wrong

The feature is a before conversations with a message the user has not opened, cleared by opening the thread. The design decisions were made before the first line of code:

The first field report arrived within hours: "I just got a new text and there was no circle." It unpacked into three separate findings, in increasing order of blame:

  1. The report itself was a false positive. The "new text" was a message the user had sent to themselves as a test — and in a conversation with yourself, the last message is always yours, so the your-own-reply rule suppresses the dot forever. Correct behaviour, surprising test.
  2. The first-run seed was swallowing exactly the wrong message. The text that arrives five minutes before the user first opens the app is the message the mark exists for, and the seed marked it read along with everything else. The seed now spares incoming traffic from the last hour.
  3. A store that fails to persist disables the feature silently and forever. If the seen map does not survive a relaunch, every launch is a "first run", every launch re-seeds everything as read, and no dot can ever outlive the session — with no error anywhere. The map now writes to bridge storage and the WebView's own localStorage, loads from whichever survived, and the boot log prints seen: N stored or seen: seeded. That one line is the entire diagnosis of any future missing-dot report.

The general shape is worth keeping: a state feature on this platform needs its persistence observable, because the failure mode of silent non-persistence is indistinguishable from the feature working on a quiet day.


3.27 The binding, not the bridge

The requirement: messages must keep accumulating while the bridge is off, and appear when it comes back on. Most of it was already true, for a reason worth stating precisely — capture never depended on the bridge. The notification listener writes to the store whenever the system has it bound, foreground service or not, and SMS sits durably in the provider and syncs incrementally on every fetch. What capture depended on was the binding.

With the bridge off there is no foreground service protecting the process, so the OS eventually kills it — and the system unbinds the listener and does not rebind it on its own. From then on RCS capture is silently dead: notification access still granted, settings toggle still on, nothing anywhere reporting a problem. Tapping Start did not fix it, because a new process does not re-establish an unbound listener.

§3.20 had already documented the repair — requestRebind() is a no-op after a kill because it early-returns unless the component was explicitly unbound, so the component's enabled state must be toggled first — and the code had never landed it. The paper was ahead of the program by nine releases. A written-down fix is not a fix; re-reading your own conclusions against the code they describe is apparently a real maintenance activity.

Landed, the repair runs at the two natural recovery moments — bridge start and app open — gated on a connected flag so a live binding is never churned. On reconnect the existing backfill sweeps whatever still sits in the notification shade, up to 25 retained messages per conversation. Verified on the device in its worst case: force-stop (the one kill that guarantees no rebind), open the app, and the system log shows binding: then service connected, followed by a 9-message sweep. What remains unrecoverable is what §3.20 already accepted: a notification posted and dismissed while the process was dead — and the next message in that conversation self-heals the gap.


3.28 Shipping it to someone who is not you

Everything up to here was built for one phone. Handing it to a second person exposed how much of "working" was actually "working here", and three things had to change before a stranger could install it.

The identity was a placeholder. com.example.g2bridge is permanent the moment anyone else installs — an applicationId cannot change without every user reinstalling from scratch — so it became com.doubleare.g2bridge while the only install in the world was the author's. That install still had a year of captured RCS, sixty profiles and a 975 MB model in it, and Android treats a renamed app as an entirely new one: new data directory, no permissions, no history. The migration was a tar out of the old app's private storage via run-as and a tar into the new one's, which carried the database, preferences, favourites and the model across intact. Two things it could not carry: runtime permissions, which the OS grants per identity and the user must re-grant, and the notification-listener binding, which is the same story. Worth knowing before the rename rather than after.

The old app then had to be disabled, not merely stopped: mid-migration it restarted itself and took port 8080, and the new bridge's /pair route answered 404 for several confusing minutes because the process answering was the old build, which had never heard of pairing. pm disable-user settles it while keeping the data as a second backup.

A token baked into a public package is not a secret. For one hand-built plugin, compiling the bearer token in was fine. For a store-distributed .ehpk — an extractable zip anyone can download — a shared constant protects nobody, and the plugin needed a way to learn this install's token. The naive alternative is worse: dropping auth entirely, on the theory that binding to loopback is enough. It is not. Loopback is shared by every app on the phone. Any installed app holding the ordinary INTERNET permission can open a socket to 127.0.0.1:8080, so an unauthenticated bridge is a backdoor that hands any of them the ability to read every message and send texts as the user — precisely the capability Android gates behind per-app SMS permissions. Loopback keeps other devices out; the token keeps other apps out, and both are needed.

So: a /pair route, deliberately unauthenticated, that does not answer with a token but instead raises a consent dialog on the phone. The user taps Allow, the per-install token crosses once, and the plugin stores it. The tap is the entire security boundary, and it is the right shape because a hostile app calling /pair cannot pass it silently — it lights up a visible prompt naming what is being asked. This is Bluetooth pairing's model, and it is worth stating plainly: the security property is not that the endpoint is secret, it is that granting access is visible and requires a human.

Two smaller findings from the same day:


3.29 A walkthrough, because every permission this app needs looks alarming

The app asks to read your texts, to send texts as you, to watch your notifications, and to run forever in the background. Each request is necessary and each one, without its reason, reads exactly like spyware. That is not a presentation problem to be smoothed over — a permission granted without understanding is a permission that should not have been granted — so the app now explains itself before it asks for anything, in a seven-page walkthrough shown on first launch and re-openable from the setup page.

What earned a page is instructive, because most of it is not about permissions at all. Three of the seven exist because each describes behaviour that would otherwise arrive as a bug report:

The privacy claim gets its own page and is stated in the negative, which is the only form that means anything: no account, no server, no analytics, nothing sent to the developer, works in airplane mode. Every one of those is checkable from the source, which is the point — the walkthrough does not ask for trust it has not earned elsewhere in this document.

Explaining and asking are the same screen. A walkthrough that only describes the steps leaves the reader to find five different Settings pages afterwards, by which point the reasons are gone. Each page therefore ends in the button that resolves it — the runtime permission dialog, the notification-listener list, battery settings, requestPinAppWidget, the model download — and each reads its real state rather than trusting a callback, so a page shows "✓ Messages allowed" whether the permission was granted a second ago or last week. The state is re-read in onResume, because returning from a Settings screen is exactly the moment it changed.

One permission is deliberately not requested with the targeted dialog: REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is a restricted permission that Play scrutinises, so the page opens the battery list instead. One extra tap, no policy exposure.

The artwork carries the argument. Each page is drawn on the same virtual pixel grid as the rest of the app (§3.14), because a diagram that explains where messages go is more convincing than a paragraph claiming it. The notification page shows a message being lifted out of the shade — literally where RCS comes from; the widget page draws the same tile in both states, because the widget's entire value is that its face tells you which one you are in; the model page draws a chip inside a wall the data never crosses. Five scenes live in one view class rather than five, since they differ only in what they draw: the grid, palette, aspect handling and animation clock are identical, and five copies of that scaffolding would be five places to fix a bug.

Responsiveness needed one rule beyond aspect-locking to width: cap the height, not the width. Width alone misses the failure that matters — an aspect-locked drawing as wide as the page is tall enough to push the step's own button off the screen, and height is what competes with content. Each drawing measures against both ceilings. No alternate layouts, no size-qualified resources; one measure function covers a small phone through an unfolded foldable.

The last pass was about the fold. Copy was cut roughly in half so every page fits on a normal phone without scrolling, Skip moved from beneath the forward button to the top corner — where an escape belongs, and where it stops occupying the position the eye reads as "continue" — and the page still scrolls when it must, because no fixed layout survives a 1.7× system font. For that case there is a fade and a "▾ more below" hint, shown only when the content genuinely overflows and hidden again at the bottom. A permanent affordance would be a lie on most pages; content clipped with no hint is content nobody reads. Verified both ways: absent at the default font, present at 1.7× with the body text visibly passing under the fade.


3.30 Unfolding a foldable makes the screen shorter

Reported as "in tablet mode the content isn't visible and you can't scroll to it". The instinct is that a bigger screen cannot show less, and the instinct is wrong. Measured on the Find N6:

width height
Folded (outer) 351 dp 805 dp
Unfolded (inner) 692 dp 763 dp

The inner display is nearly twice as wide and 42 dp shorter. Every page in this app was a non-scrolling LinearLayout sized to fit the outer screen exactly — the layout comment said so proudly, "nothing scrolls, every page is sized to fit" — so unfolding clipped the bottom of each one with no way to reach it. A design that fits by construction is a design that breaks the moment the construction changes.

Two independent fixes, because they address two different failures:

  1. Every page scrolls now, with fillViewport so it looks identical whenever it already fits. This is the safety net: content the user cannot reach is worse than content that scrolls, and no fixed layout survives every screen, font scale and locale.
  2. The width gets used. Side padding grows through values-w600dp, and the walkthrough gains a genuine two-pane layout in layout-w600dp — artwork in one column, words and the action in the other. Same ids, so the activity binds one way and the resource system picks the arrangement.

The subtler bug was the artwork. Aspect-locked drawings scale with width, so the hero that was a comfortable 269 dp tall folded became ~360 dp unfolded — growing into the very height that had just shrunk, and pushing the status card off the page. On a foldable, width-driven sizing is a height bug. Every drawing now takes a height ceiling, raised in two-pane layouts where it has its own column to fill rather than a page to share.


3.31 Deleting the feature I argued for

§3.22–3.24 are the record of a profile panel: a local model, 218 MB of it, then 975 MB, then a four-section analysis with a freshness clock. §3.23 is titled "the verdict overturned" and concludes the 1B earns its place. It did not.

The report that ended it was one sentence about reliability, not quality: repeated failed connections and bridge timeouts, with the app running and the bridge on. And then the sentence that settles any argument about a feature — "it has to 99.999999% work every time or people aren't going to use it."

I had built the panel, defended it through one reversal, and measured it favourably. The measurements were real and beside the point: they were of the panel, and the thing that had broken was the bridge. A 975 MB model resident in a process whose entire job is to answer a localhost request in under a second is a bet that inference and availability do not compete. On a phone they compete for exactly the resource the OEM is watching — memory — and the process that loses is the one holding the socket.

So the feature came out entirely: the model, the columns, the worker, the endpoint, the screen. Version 1.0.0 is a deletion.

A feature's own metrics cannot tell you what it costs the system around it. Every number I had was scoped to the panel. None of them could have shown the bridge dying, because the bridge was not what I was measuring. The user, who was measuring nothing, was right first.

There is a second lesson, and it is about who gets the last word. I had pushed for this feature twice. When the person who actually uses the thing says it is not reliable enough, that is not a hypothesis to test against my benchmarks — it is the requirement, and my benchmarks were answering a different question.


3.32 A foreground service with a six-hour budget

Removing the model did not fix it. The bridge still went away.

The cause was one word in the manifest. The service declared android:foregroundServiceType="dataSync", which on API 35+ Android caps at six cumulative hours per 24. At the cap the system stops the service and then refuses to start it again until the app is opened by hand. For a bridge whose whole purpose is to be reachable while the phone sits in a pocket, that is not a limit — it is a daily execution.

Worse, startForeground() was called unguarded from onCreate(). A refused start throws; throwing out of onCreate() takes the process down. So at the cap every restart attempt crashed rather than failed, and the watchdog's next tick crashed again.

Three changes, in order of importance:

  1. specialUse (types=0x40000000, verified in dumpsys), which has no time budget. This is a genuine special use: a personal LAN server for a paired device. The declaration requires a PROPERTY_SPECIAL_USE_FGS_SUBTYPE property nested inside the <service> element — outside it, the manifest merges and the type is silently wrong.
  2. The foreground start is guarded. A refused start is survivable; a crash is not.
  3. onTimeout() is implemented even though it is dead code under specialUse. If the type ever changes back, a service that does not stop itself there is killed with ForegroundServiceDidNotStopInTimeException.

Then the part that mattered more than any of them. START_STICKY is a request, and an OEM killing for battery reasons is under no obligation to honour it. Measured on the device: the process stays alive — the system keeps the notification listener bound independently — while the HTTP server is gone. From the glasses the bridge is simply unreachable, and nothing on the phone looks wrong.

So availability cannot rest on the service surviving; it has to rest on something outside the process noticing. An AlarmManager alarm is held by the system, so it fires whether or not the app is alive, and re-arms each time. Every two minutes: if the user wants the bridge running and it is not, start it. Idempotent, and gated on the user's own switch — a watchdog that resurrects a service the user turned off is a bug wearing a feature's clothes.

Every event that already woke the process now also repairs it: opening the app, an inbound SMS, the listener reconnecting. Each was a free repair being discarded. MY_PACKAGE_REPLACED covers the case unique to sideloading — an update force-stops the app and takes its alarm with it. Verified: process killed, socket refused, serving again in 2 s with no user involvement; and after a force-stop, back in 4 s.


3.33 The third column that restated the second

The thread view had three columns: an action rail, the messages, and a panel summarising the messages. The panel was the surviving half of §3.24's profile — counted fields only, no model.

On 576 pixels that is 40% of the display spent restating what the other 40% already shows. The messages are the product; a summary of them is a summary of the thing the user came to read, placed where the thing itself could have been.

Removed. The conversation now takes everything the rail does not.

A summary earns its place only when the source is absent or too long to scan. Neither was true here: the source was four lines away and the screen holds ten. The panel was answering a question — "what is this conversation about?" — that the reader had already answered by opening it.


3.34 The root cause I was certain of, and was wrong about

The next report was the same shape, and sharper: opening a message lags for a long time and then reports a bridge timeout, with the bridge on.

I read the code and found a compelling cause. The SMS sync computed its incremental cursor as MAX(sent_at) WHERE source='sms', and — because Google Messages posts a notification for SMS too — almost every text is recorded from its notification first, as source='notif'. When the provider copy arrives it merges into that row and leaves source alone. So the mark would barely advance and the re-read window would widen by a day every day, re-scanned as often as every three seconds. An independent audit reached the same conclusion. It is a good story and the mechanism is real.

It was not happening. Pulled from the device:

newest message overall                    : 2026-08-21 15:09:15
high-water (max sent_at where source=sms) : 2026-08-21 15:09:15
rows the sync re-reads every pass         : 1

Enough SMS do win the "fuller copy" comparison to keep the mark current. The store was 2,298 messages and 680 KB; every endpoint answered in 20–50 ms; the summary worker had zero stale rows and so was not looping. Every quantity I had predicted would be large was small.

And the thing actually wrong was visible in five seconds of dumpsys: the bridge was not running.

Reading code tells you what can happen; only the device tells you what does. Both I and a fan-out of independent reviewers derived the same wrong root cause from the same correct mechanism, because a mechanism that is real is not thereby active. The fixes stayed — they cannot regress, and the high-water mark is now recorded explicitly rather than inferred — but they were filed under hygiene, not cause, and the search continued.


3.35 The timeout that killed the server, and the fix that caused it

Two things then happened in the wrong order, and the order is the interesting part.

Convinced the handlers were blocking Ktor's event loop, I wrapped every route body in withContext(Dispatchers.IO). This is the standard advice, it compiled, and the endpoints still answered. Then, testing something else, three aborted requests in a row and the bridge stopped responding — to everything, including endpoints that touch no database. The listening socket was still open. Connections were still accepted. Nothing was ever answered.

Reproduced deliberately, it took three concurrent aborted requests, every time. Rebuilding the pre-change code and repeating the test: it survived twelve. I had introduced it.

The chain: the glasses abort a request at their timeout; the abort cancels the call coroutine; withContext throws CancellationException at that suspension point; StatusPages' exception<Throwable> catches it — cancellation is a Throwable — and calls call.respond on a connection that no longer exists; responding inside a cancelled coroutine throws again, now out of the exception handler, and CIO treats that as a pipeline failure.

The StatusPages catch-all was pre-existing. What my change added was a reachable suspension point inside the handler for the cancellation to land on.

Two corrections. Cancellation is rethrown rather than answered — there is nobody to answer. And the dispatcher hop is gone entirely: the blocking work is short, and the alternative was worse than the problem. While removing it I checked the idiomatic replacement and found that callGroupSize, connectionGroupSize and workerGroupSize are inherited from the base engine configuration and never read by CIOjavap over the shipped ktor-server-cio-jvm-2.3.12 shows zero references. They belong to Netty and Jetty. A comment claiming that pool sizing was what fixed this would have been a lie left for the next reader.

Measured after: 1,440 aborted requests interleaved with 4,320 real ones over three minutes, median 21 ms, no latency drift, resident memory slightly down.

Three lessons, and the middle one is the one I keep relearning:


3.36 A guard that could never fire

That still left the client. §3.32's watchdog restarts the bridge within two minutes, and the glasses were written to wait it out: an error screen that retries indefinitely with backoff, rather than one only a relaunch clears.

const mine = gen
const again = (delay: number) => {
  setTimeout(() => {
    if (gen !== mine || view.name !== 'error') return
    void boot(false)
    again(Math.min(15000, Math.round(delay * 1.6)))
  }, delay)
}

gen is the renderer's generation counter, incremented on every render — except that show() returns early without incrementing when the screen it is asked to draw is identical to the one already up (§3.25's list-selection fix). One error screen replacing an identical error screen is exactly that case. So gen !== mine is never true, and the guard is decoration.

With the guard inert, the two remaining lines compound: void boot(false) is launched unawaited, and the timer re-arms unconditionally. When that unawaited boot fails it reaches the same catch and starts another chain, while the first is still ticking. The number of retry chains doubles once per failure. Simulated over two minutes of an unreachable bridge:

requests to /conversations concurrent retry chains
before 161,472 42,016
after 24 1

A WebView allows roughly six sockets per origin. Past that, the retries alone starve the request the user is waiting on, and a recovering bridge does not help — by then the storm is on the client. This is the mechanism that turns "the bridge blipped" into "opening a message hangs and then says it timed out", which is the sentence the whole investigation started from.

The fix is one module-level timer, cleared before it is ever re-armed, with the error screen's own presence as the continue condition. The backoff also had to move out of the call chain: boot()'s own catch re-schedules on failure, so the caller's grow-the-delay branch never ran and the interval stayed at four seconds forever. State that must survive a call cannot live in its arguments.

A guard built on a counter that another function deliberately does not increment is not a guard. The early return in show() is correct and documented; the retry code was written against a mental model of gen rather than its behaviour. Nothing in the type system, and nothing in review, connects those two facts — only asking "what makes this expression true?" does.


3.37 The rename left the exemption behind

One measurement outranks everything above, and no code change reaches it.

$ adb shell dumpsys deviceidle whitelist | grep g2bridge
user,com.example.g2bridge,10585

Battery optimisation is exempted for com.example.g2bridge — the package name this app used before §3.28's rename to com.doubleare.g2bridge. The current package has never been on that list. The exemption was granted once, to an application id that no longer runs, and the rename carried the code, the data and the user's belief that the setting was still in force.

The consequences are in the system log, unprompted:

OplusHansManager : pkg=com.doubleare.g2bridge cannot transition from R to M, importance=fg-service
OsenseKillAction : don't check adj for non perceptible fgs app: ProcessInfo{...g2bridge...}

ColorOS's freezer is repeatedly trying to demote the process and is blocked only because a foreground service is running; its kill manager spares it for the same single reason. The app is living on one guarantee, with no margin. And a frozen process still holds its listening socket — so the glasses connect, and then wait, which is indistinguishable from a slow bridge and reads to the user as a timeout.

The app's setup screen reports this correctly: "Keep the bridge awake" shows as not granted, because it checks the current package. It has been telling the truth to someone who had already granted it, once, to a name that no longer exists.

A permission or exemption keyed on application id does not survive a rename, and nothing warns you. The data migration was planned and executed (§3.28). The system-side state — battery whitelist, and anything else the OS stores per application id — was not, because it lives outside the app and is invisible to it. Anything granted through a system dialog should be re-verified after a rename, and the stale install left behind still holds the entry.


4. Limitations (what this platform will not let you do)

Several requirements could not be met as stated. These are platform limits, verified against the SDK type definitions and the documentation, not implementation shortcuts:

Wanted Reality What shipped instead
Restore the previously selected list item on "back" Impossible. ListItemContainerProperty exposes only itemCount, itemWidth, isItemSelectBorderEn, itemName. The selected index is report-only — it arrives in List_ItemEvent and can never be set. Every rebuild resets the highlight to row 0. The list window is re-anchored so the item you opened is the first row — one swipe from the default selection instead of scrolling from the top.
Scroll a long message Impossible. TextContainerProperty has no scroll or offset field; the docs state "no programmatic scroll position". Explicit [v Next page] control; content pre-paginated to the measured line budget.
A live page counter on the paging button Impossible. Lists cannot be updated in place — any label change requires a full page rebuild, which resets the selection. The button shows the page count; the current page is rendered in the text as (n/N).
Distinguish a real tap from the host's post-render settle event Impossible by content. Both arrive as {containerID, containerName, currentSelectItemIndex} with no eventType — CLICK is 0 and protobuf drops zero values. A 600 ms settle window after each render, during which taps are ignored. Without it the app navigates itself the moment a page appears.
Know which button is selected while the mic is live Unreliable. The host may omit listEvent during audio capture, leaving a tap with no index. The view declares a primary action as the index-less fallback, double-tap always cancels, and the screen states both gestures.
Use the OS recogniser without a microphone permission Impossible. SpeechRecognizer requires RECORD_AUDIO even when audio is supplied via EXTRA_AUDIO_SOURCE and the mic is never opened. Declared and requested, with the reason documented in the manifest so it does not read as overreach.
Detect a wrong-typed Bundle extra Impossible at compile time. putExtra accepts any type; a wrong one is silently dropped (a boolean where EXTRA_ENABLE_FORMATTING wants "quality" simply does nothing). Constants and their types verified against android.jar with javap rather than trusting docs.
Reply to RCS in a dormant thread Impossible. The RemoteInput action dies with its notification (§3.9). Falls back to SMS, and the transport actually used is reported back to the display.

A cross-cutting one: the display's only line-break control is \n. There is no font sizing, no alignment beyond left/top, no colour, and no animation. Every layout decision is made in units of characters and lines, measured empirically from screenshots, because the platform exposes no metrics API.


5. What did and didn't work — summary

Worked Didn't work
Non-default SMS companion (no ROLE_SMS) Ktor CIO + HTTPS (engine can't do TLS)
Plaintext HTTP + bearer token on LAN Self-signed HTTPS to a WebView
input -d 0 for the foldable adb pm grant / allow_listener on ColorOS
List with zOrderIndex, sized ≤14×46, paged 20×64 list; text→list rebuild race
READ_CONTACTS + PhoneLookup name resolution
Notification listener for RCS receive (full text) Reading RCS from the SMS provider
MessagingStyle-only filter; drop redacted; rescan on unlock Capturing "any notification with text" (grabbed status banners + lock-redacted copies)
RemoteInput reply → real RCS, silent Share sheet / ACTION_SENDTO / shortcuts (all need a manual tap in Messages)
Google's on-device recogniser via EXTRA_AUDIO_SOURCE (zero app size, punctuation, biasing) Assuming an OS recogniser must use the microphone
Reporting which engine served each transcript A silent Google→Vosk fallback that hid total failure for cycles
javap over android.jar to confirm API shape Trusting doc pages for constant types
Vosk on-device STT (offline, free, PCM-native) Web Speech API; Whisper-on-Mac; the 1.8 GB Vosk model (~2.9 GB on disk)
sysEvent taps + auto-stop + debounce textEvent-only input during audio capture
Envelope-agnostic gesture detection Assuming a container type implies the event envelope
Strict number match + refuse-on-ambiguity + visible sentTo Bidirectional endsWith matching with lastOrNulldelivered a reply to the wrong person
Dedup on the message's own time Dedup on StatusBarNotification.postTime (changes on every re-post)
base: './', stripped crossorigin, ws:// whitelisted Dev-server-only testing — none of these three failures can surface before packaging
Loopback binding + adb forward (one origin everywhere) A --dev flag / environment-switched origin
One conversation row + transport enum, keyed on participants Two views merged at the presentation layer
Separate key namespaces (tel:/short:/group:/name:) A similarity rule deciding whether two conversations match
Containment matching bounded by the platform's 1024-char truncation Full-body content hashing; edit distance; Jaccard
Dedup on sender-time, order on receipt-time One timestamp doing both jobs
Synthetic PK + (source, source_id) The provider _ID (recyclable; collides across sms/mms)
Filtering the names list and the keys list together ArrayAdapter's built-in Filter (positions in filtered space)
Grouping the RCS inbox on EXTRA_CONVERSATION_TITLE EXTRA_TITLE (Messages sets it to "A, B, C: latest sender")
Naming both sides of every rendered line A leading > marker — lost on every wrapped line
encodeDefaults = true on the wire Default-valued fields, which never serialise at all
Pacing piped audio at 4x real time; 700 ms trailing silence; EXTRA_SEGMENTED_SESSION Raising the endpointer's silence thresholds — the extras are ignored
Recording the recogniser's callback trace Four competing hypotheses and two shipped non-fixes
A say-generated PCM fixture posted straight to /stt Re-testing through the glasses each round
specialUse FGS (no time budget) dataSync FGS — capped at 6 cumulative hours per 24 on API 35+
An AlarmManager watchdog held by the system START_STICKY as an availability guarantee
Guarding startForeground() An unguarded foreground start in onCreate() — a refusal becomes a crash
Rethrowing CancellationException StatusPages exception<Throwable> responding to a cancelled call — killed the engine
Leaving blocking work on the call thread withContext(Dispatchers.IO) per handler — added the suspension point cancellation lands on
Reading javap output for callGroupSize Trusting that an inherited engine property is read (CIO reads none of them)
One retry timer, cleared before re-arming A guard on gen, which show() deliberately does not increment
WAL, batched transactions, an index for the dedup scan One transaction per statement; contact IPC inside the write lock
Recording the sync's high-water mark explicitly Deriving it from MAX(sent_at) WHERE source='sms' — the column that merging never updates
Checking insert()'s return value Assuming a failed insert throws (it returns -1)
Re-verifying system-granted exemptions after a rename Assuming the battery whitelist follows the application id
Pulling the database off the device before diagnosing A mechanism that is real and assumed therefore active

6. Cross-cutting lessons

  1. Constraints eliminate options faster than they add work. "No computer, truly free" collapsed a huge STT design space to exactly one viable answer (on-device Vosk).

  2. Compile-time success ≠ runtime capability. CIO's sslConnector and the SDK's list API both compiled and both failed on device. The only ground truth was the hardware.

  3. Instrument the constrained surface first. The DOM mirror and on-glasses debug line paid for themselves many times over; blind round-trips on real hardware are the expensive path.

  4. Read the platform's actual behavior, not its docs. The list ceiling, the sysEvent input reroute, and the protobuf zero-drop were all undocumented or under-documented.

  5. Name the honest scope — then keep testing it. Declaring "RCS send is impossible" was right about the direct API and wrong about the system as a whole. Honest scoping avoided a fragile scraper; re-examining the claim (prompted by a user question) found the legitimate path. Both moves mattered, in that order.

  6. Real data invalidates clean assumptions. The notification pipeline passed every synthetic test and still surfaced a pairing banner and a wall of "Sensitive notification content hidden." Dumping the device's actual notification structure — not reasoning about what it should contain — produced the correct filter in one pass.

  7. Inference with real-world consequences must be visible. The wrong-recipient bug (§3.10) survived a build cycle because the system silently chose a recipient and never displayed the choice. The fix was two-part: make the matching strict, and surface the result. Correctness and observability were not separate tasks here.

  8. Deployment is a distinct failure domain, not a final step. Three defects (§3.11) — absolute asset paths, crossorigin, and the missing ws:// whitelist entry — were undetectable in development by construction, because the dev server doesn't exercise the packaged environment. Budget for a packaging pass that assumes nothing transfers.

  9. An independent audit of the artifact finds what author-testing cannot. The missing WebSocket whitelist entry would have shipped: the build succeeded, installed, ran, and displayed messages — it would simply have stopped receiving new ones, indistinguishable from a quiet afternoon. It was caught by reviewing the package against the platform's rules, not by exercising the code.

  10. Sometimes the right answer to "add a switch" is to remove the difference. The dev/production origin problem dissolved once adb forward made one URL correct everywhere. A configuration flag would have worked and would have permanently split the code into a tested path and a shipped path.

  11. A gesture that means different things in different places is a quiz, not an interface. The strongest UI change in the project was making double-tap mean exactly one thing everywhere and promoting every other action to a visible, selectable control (§3.12). Enforce it structurally — one branch, before any view-specific logic — so it cannot silently re-acquire exceptions.

  12. Measure the medium; don't trust the unit the docs give you. "Paginate at 400–500 characters" is sound advice that fails for conversational text, which is short in characters and tall in lines. Pagination only worked once it was computed in rendered lines, with the line height and characters-per-line measured off actual screenshots — the platform exposes no metrics API, so the screenshots were the metrics API.

  13. A fallback without attribution is indistinguishable from success. The Google STT path failed on every call for several cycles while Vosk quietly answered; the endpoint returned good transcripts throughout and the failure was invisible (§3.14). Anything that degrades gracefully must report which path it took — otherwise graceful degradation is just undetectable degradation. Adding one engine field to the response ended a multi-round debugging loop instantly.

  14. Ask what the platform already provides before choosing what to bundle. A 204 MB model download and a long accuracy chase existed to approximate a recogniser the OS was already shipping for free (§3.14). The reflex was "which library?"; the better question was "what's the operative capability, and who already has it?"

  15. Where the type system stops, verify against the artefact. Bundle.putExtra accepts any type, so a boolean passed where a String constant was required compiled cleanly and did nothing. Decompiling android.jar with javap settled several API questions the documentation pages could not (§3.14).

  16. "It fits" is not "it's reasonable." Justifying a 1.8 GB download because the device had 254 GB free confused capacity with cost to the user, who rejected it immediately (§3.14). Measure the thing the user actually experiences — a download, a wait, an install size — not the headroom around it.

  17. On a small display, controls and content compete for the same pixels. Each button row cost ~40px of 288px, roughly 1.4 lines of text. Adding one convenience control nearly doubled a thread's page count. Interface density is not an aesthetic question here; it is arithmetic, and it should be done before adding the control, not after.

  18. A wire format that drops defaults makes "true" unrepresentable. Protobuf dropping zero values (§3.4) and kotlinx.serialization omitting default-valued properties (§3.17) are the same defect in two unrelated serializers, encountered months apart. Both times the receiving side read a meaningful value as absent, and absent as false. Encode defaults explicitly, and treat any boolean that is meant to be true by default as a hazard.

  19. Latency is part of the interface contract. Opening the microphone after rendering the "Listening…" screen is correct in every sense except the one that matters: the user starts speaking at the tap, not at the repaint (§3.15). When an action has a visible trigger, the capability must be live at the trigger — the confirmation can lag.

  20. Spending a user's resources requires asking. A 125 MB download issued automatically on first use is defensible as caching and indefensible as consent (§3.16). The test is not whether the resource is available but whether the person would have agreed to spend it, which is answerable only by asking them.

  21. Scrutinise a number hardest when it flatters the work. "250 MB to 10 MB" conflated install size with on-disk footprint and overstated the win by an order of magnitude (§3.16). The real figures — 22 MB to 15 MB, plus 204 MB made optional — are less impressive and actually true. Errors in one's own favour survive review longest.

  22. A marker that does not survive wrapping is not a marker. Direction indicated by a leading > disappears on every wrapped line, which on a 42-character display is most of them (§3.17). Test formatting against real content at real width; a convention that only works on short strings has not been tested, it has been imagined.

  23. Verify the fix against the live system, not the diff. The grouping change looked complete and correct in review; querying the running endpoint is what revealed the missing incoming field that would have silently negated it (§3.17). Reading the code you just wrote confirms intent, never behaviour.

  24. A "hint" API that is silently ignored is indistinguishable from one that works. Three silence-threshold extras were set, compiled, shipped, and had no effect whatsoever (§3.18). Nothing failed; the call simply did nothing. Where an API documents a parameter as advisory, verify the effect, never the call — and treat a fix you have not observed working as a hypothesis you have deployed.

  25. When a fix does not hold, suspect the layer before the parameter. Two rounds were spent tuning the endpointer's thresholds. The actual causes were the rate audio was delivered, the absence of trailing silence, and the wrong session mode — none of them reachable by adjusting the knob that was being adjusted (§3.18). Repeatedly changing the magnitude of a setting is a sign the setting is not the mechanism.

  26. Instrument before theorising. Four plausible explanations existed with no way to choose between them by inspection. Recording the recogniser's callback sequence eliminated three of them with a single request and pointed at a cause none of them had included (§3.18). The trace cost twenty minutes; the guessing had cost two releases.

  27. Build the fixture instead of using the person as one. say plus afconvert reproduced the exact failing input as a file in three lines, turning a speak-into-the-glasses-and-report round trip into a one-second local loop (§3.18). It should have been the first move rather than the fourth, and the cost of not building it was two shipped non-fixes.

  28. Choose a constant by finding where it breaks. The audio pacing was not set to a plausible-sounding number; 1x through 32x were measured, the break was located between 16x and 32x, and 4x was chosen for the margin (§3.18). A tuned value with a known failure boundary is a different kind of object from a tuned value that merely worked once.

  29. Verifying the wrong endpoint is worse than verifying nothing. /stt/status reported speech healthy throughout, because it checks whether an engine exists and never exercises recognition (§3.18). A green check on an adjacent path reads exactly like a green check on the real one, and it ended two debugging sessions early.

  30. A negative literature result is a finding, not a failed search. Thirty sources established that nobody has merged notification-sourced RCS with provider-sourced SMS into an ordered store, and that the mature projects with both capture paths deliberately join neither — they relay live and store nothing (§3.19). Knowing the field's escape hatch is unavailable to you is worth more than another half-relevant paper.

  31. Make the dangerous state unrepresentable rather than guarded. "Split when unsure" as a rule requires remembering it at every call site forever. As separate key namespaces it becomes a property of the type: an uncertain key cannot equal a certain one, so the wrong merge is not prevented, it is impossible (§3.19). Prefer the version that cannot be forgotten.

  32. Let the platform supply the threshold. Naive prefix matching would merge "ok" into a later "ok thanks". The fix was not a tuned similarity cutoff but a platform constant — a notification body is a prefix only when it is exactly the 1024 characters the platform truncates at (§3.19). A constant you can cite beats a threshold you chose.

  33. The user's stance can delete a hypothesis, and that is the system working. The research argued for modelling gaps; the product owner said what is missed is missed (§3.20). Both were right about their own question. Research establishes what is true; only the person who lives with the product decides what is worth showing.

  34. An invisible character in source is unreviewable, and a no-match replace reports success. A string literal containing U+0001 read as empty in every inspection, and the fix keyed on its visible text matched nothing and said nothing (§3.21). Where a value might contain something unprintable, assert on its bytes — and treat "the edit applied" as distinct from "the edit changed anything".

  35. Read the system's stored state, not your own diff. Third instance in this project (§3.17, §3.18, §3.21). The separator bug was invisible in source and obvious the moment the preference file was read off the device. If a fix touches persisted state, the persisted state is the only acceptable proof.

  36. A mechanism that is real is not thereby active. The incremental-sync bug in §3.34 is genuine, derivable from the source, and was independently confirmed by a fan-out of reviewers. It was also not happening: the device showed a high-water mark exactly current and a re-read window of one row. Code review can establish what a system can do; the running system is the only authority on what it is doing. Pull the database before naming a root cause.

  37. A client timeout is an input to the server, not an edge case. The glasses abort every request that passes eight seconds, so aborted requests are among the most common events the bridge will ever see. Handling one badly — catching the cancellation and answering a closed socket — stopped the server entirely (§3.35). Any handler with a timed-out client should be tested with a timed-out client, deliberately and concurrently.

  38. The textbook fix for the problem you assumed costs more than the problem when the assumption is wrong. Moving handlers to Dispatchers.IO is standard, correct advice for a blocking Ktor handler, and here it introduced a defect strictly worse than the one it addressed — three aborted requests and the bridge stopped answering. It passed review; it was caught by running it on hardware. Diagnose before prescribing, and treat a fix adopted on general principle rather than on evidence as unverified until measured.

  39. A guard is only as sound as the expression it tests. The retry loop's gen !== mine could never be true, because the renderer deliberately does not increment gen for an unchanged screen (§3.36). Both behaviours were correct and documented; nothing connects them except asking, of every guard, "what would make this true?" — a question worth asking out loud, because neither the compiler nor a reviewer will ask it for you.

  40. A retry that can start a second retry is a fork bomb with a friendly comment. The error path was written to be gentle and read as gentle; unawaited and unconditionally re-armed, it produced 42,016 concurrent chains in two minutes and starved the user's own request out of the socket pool (§3.36). Retry logic needs a stated invariant — exactly one pending attempt — enforced by a single timer, not by a comment.

  41. System-granted state does not survive a rename, and nothing tells you. The battery exemption stayed attached to the old application id through §3.28's rename, leaving the app one guarantee away from being frozen while its own setup screen truthfully reported the permission as missing to a user who had already granted it (§3.37). Data migration was planned; the state the OS keeps about the app was not, because it is invisible from inside. After any identity change, re-verify everything granted through a system dialog.

  42. The person who uses the thing outranks your benchmarks. A feature I had built, defended through one reversal, and measured favourably was removed on a single sentence about reliability (§3.31). My measurements were sound and scoped to the feature; the cost landed on the system around it, which I was not measuring. When the user says it is not reliable enough, that is the requirement, not a hypothesis to be tested.


7. Future work


8. Conclusion

Every meaningful decision in this build was made against a constraint rather than from a menu of good options. The result is a system that reads your SMS and RCS on a stamp-sized monochrome display, transcribes your voice entirely on the phone, and replies — as real RCS when the conversation supports it, SMS otherwise — with nothing metered, nothing offloaded, and no account to sign into.

Three lessons compound. The first is that constraints are specifications: when the environment refuses the obvious path (TLS, a cloud STT, a documented list size), the refusal is the design brief. The second is subtler — "impossible" deserves periodic re-testing. The project's best capability, silent RCS replies, existed the entire time in a subsystem built for watches and cars. It was found not by better documentation but by taking a user's question seriously enough to go look again.

The third arrived last and cost the most. That same capability shipped a bug that sent a message to the wrong person, because a convenience heuristic was doing work that was actually authorization, and because the system never said aloud which recipient it had inferred. The corrective was not only stricter matching but visible matching. A system that acts on your behalf in the physical world — sending a text, spending money, opening a door — should be built to refuse when uncertain, and to narrate what it decided when it acts. On a 576×288 display with three gestures, there is room for exactly one line of that narration. It turns out to be the most important line in the interface.

A fourth lesson came from the releases after the feature work was finished, and it is the one that changed how the rest of the project was run. Being correct and being available are different engineering problems, and the second is harder to see. Everything the system did, it did well; it simply was not there when reached for. The causes were not in the code that does the work — they were a manifest attribute with an undocumented daily budget, an error handler that treated a client hanging up as a server error, a retry loop whose guard tested a counter that another function deliberately never changed, and a system permission that had quietly detached from the app during a rename. None of these are visible in a diff, and several survived review by readers specifically looking for them.

What did find them was running the thing and measuring it: pulling the database off the phone instead of reasoning about its contents, reading the OEM's own log lines about what it intended to do to the process, and deliberately timing out requests to see what a timed-out request costs. Twice in the final stretch a confident, well-supported diagnosis was overturned by a five-second measurement — once in the project's favour, once very much not. For a system that must work every time, the unit of evidence is the running system. A mechanism you can derive from the source is a hypothesis; the device is the only thing that can promote it to a cause.


Appendix A — Original spec: android-bridge (Path A)

Reproduced verbatim as delivered at project start. Note where reality diverged: the "Ktor CIO + HTTPS" requirement is technically impossible (§3.2), and "RCS is out of scope" held for reading via the SMS provider but was later superseded for both receive and send via the notification subsystem (§3.6, §3.9).

android-bridge — build spec (Path A)

Claude Code: generate a fresh Kotlin/Gradle project here using current stable versions
(don't copy a pinned skeleton — resolve versions at build time). Target this contract.

What it is
A non-default SMS reader/sender. It must NOT request `ROLE_SMS` — Google Messages stays the
user's default SMS app. This runs alongside it as a passive companion.

Permissions (runtime)
`RECEIVE_SMS`, `READ_SMS`, `SEND_SMS`. Request at runtime; app is sideloaded, so no Play
default-handler requirement.

Components
* `BroadcastReceiver` on `android.provider.Telephony.SMS_RECEIVED`.
* SMS content-provider queries for recent threads and messages.
* `SmsManager.sendTextMessage(...)` to send.
* Embedded HTTP + WebSocket server (Ktor `CIO`), token-authenticated.
* Foreground service to keep the server + receiver alive (persistent notification).

HTTP/WS contract (consumed by the glasses plugin)

GET  /threads          -> 200 [{ id, name, snippet, unread, ts }]
GET  /thread/:id        -> 200 [{ from, text, ts, incoming }]
POST /send  {id, text}  -> 200 { ok: true } | 4xx { ok:false, error }
WS   /events            -> server pushes { type:"new", threadId } on inbound SMS

* CORS: allow the plugin's WebView origin (`Access-Control-Allow-Origin`) + handle OPTIONS
  preflight.
* Auth: require a bearer token (also passed by the plugin); reject otherwise.
* Serve over HTTPS for on-device use (self-signed acceptable for LAN; document the trust
  step).

Coexistence rules
* No default-handler role.
* Suppress message notifications (let Google Messages own them); only the foreground-service
  notice.
* Mirroring sent messages back into the shared SMS store is best-effort on modern Android —
  don't block on it.

Definition of done (before touching the glasses)
* Installs, permissions grant, foreground service stays up.
* `curl` against all four endpoints works on-device (receive a real SMS -> `/events` fires;
  `POST /send` delivers a real text).
* RCS is out of scope here (SMS/MMS only) — see guide Paths C/D for RCS.

Appendix B — Original spec: glasses plugin manifest (app.json)

Reproduced verbatim as delivered at project start. The shipped manifest differs: it adds the required edition, min_app_version, min_sdk_version, and entrypoint fields; the STT origin was removed entirely (speech recognition became on-device, §3.7); and the bridge origin resolved to http://localhost:8080 because the plugin's WebView runs on the same phone as the bridge (§3.2).

{
  "package_id": "com.example.g2texting",
  "name": "G2 Texting",
  "version": "0.0.1",
  "description": "Read and reply to texts on the Even G2 by voice.",
  "supported_languages": [
    "en"
  ],
  "permissions": [
    {
      "name": "g2-microphone",
      "desc": "Capture voice to dictate replies."
    },
    {
      "name": "network",
      "desc": "Talks to the local SMS bridge and the speech-to-text service.",
      "whitelist": [
        "https://REPLACE_WITH_BRIDGE_ORIGIN",
        "https://REPLACE_WITH_STT_ORIGIN"
      ]
    }
  ]
}

Appendix C — Final API surface

Everything below is token-authenticated (Authorization: Bearer <token>) over HTTP on the LAN; the WebSocket takes the token as a query parameter.

POST /pair                     -> { status: "pending" }                    # unauthenticated
                                | { status: "granted", token }             # after the user taps Allow

GET  /conversations            -> [{ id, name, snippet, ts, transport, group, lastFrom }]
GET  /conversation/:id         -> [{ from, text, ts, incoming }]
GET  /favorites                -> [{ … as /conversations }]                # the six chosen on the phone
POST /send      {id, text}     -> { ok, transport: "rcs"|"sms", sentTo, error? }

GET  /threads                  -> [{ id, name, snippet, unread, ts }]      # SMS provider, direct
GET  /thread/:id               -> [{ from, text, ts, incoming }]           # SMS provider, direct
GET  /notifications            -> [{ sender, text, ts, app, conversation, group, incoming, key, canReply }]
POST /rcs-reply {key, text}    -> { ok, transport: "rcs", sentTo, error? } # 410 if stale

GET  /stt/status               -> { state, ready, lastError, lastPcmBytes, lastTrace }
POST /stt       <raw PCM>      -> { text, engine }         # 16kHz s16le mono, on-device
WS   /events?token=…           -> { type: "new"|"notif", threadId }

The unified pair — /conversations and /conversation/:id — is what the glasses actually read (§3.19); it merges SMS and captured RCS into one time-ordered store. /threads and /thread/:id remain as direct reads of the SMS provider and are what the earliest builds used. POST /pair is deliberately unauthenticated: it is how a tokenless plugin becomes a tokened one, and the user's tap in the app is the entire security boundary (§3.28).

POST /send selects its transport: a RemoteInput reply (real RCS) when exactly one live notification strictly matches the thread's number, otherwise SmsManager. sentTo reports the resolved recipient and is displayed on the glasses — see §3.10 for why that matters.

/conversations never waits on the SMS provider. The scan runs beside the request on a background thread and announces itself over /events if it stored anything; the request answers from the durable store (§3.34–3.35). /events sends a keepalive ping every 20 s, because the handler only ever writes and would otherwise never learn that a client is gone.

A /brief/:id endpoint existed between 0.6.0 and 1.1.0 and served the conversation panel described in §3.22–3.24. It was removed with the rest of that subsystem (§3.31).

Appendix D — Packaging checklist

Each of these fails silently on-device and cannot surface against a dev server (§3.11):