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:
- Self-contained. Everything runs on the glasses ↔ phone link. No companion server, no cloud API, no developer laptop in the runtime path.
- 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:
- "Risks detected." ColorOS/OxygenOS (and Play Protect) flag any sideloaded app requesting SMS permissions as potential premium-SMS fraud. Expected false positive; the fingerprint (unsigned + SMS perms), not behavior, triggers it.
adbcan't grant permissions here.pm grantandcmd notification allow_listenerboth threwSecurityException(shell uid lacksGRANT_RUNTIME_PERMISSIONS). So contacts and notification access had to be granted on-device, not scripted.- Foldable input routing. The device exposes two internal displays;
adb shell input taphit nothing (mCurrentFocus=null) until targeted explicitly withinput -d 0. Diagnosing this required a screenshot (itself corrupted by a "multiple displays" warning until captured via an on-device file). This single flag unblocked all automated testing.
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:
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 carrycategory=msg,android.template=MessagingStyle, and anandroid.messagesbundle array of{sender, text, time}. Fix: require MessagingStyle (EXTRA_MESSAGES), drop the plain-text fallback entirely, and skipFLAG_GROUP_SUMMARY(an aggregate with null text) andFLAG_ONGOING_EVENT.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 onACTION_USER_PRESENTso unlocking refreshes the inbox with full content. (A user can also set lock-screen notifications to "show all content".)The obvious dedup key is the wrong one. The first dedup guard keyed on
(StatusBarNotification.postTime, sender, text)and looked sufficient — it wasn't.postTimeis 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 owntimefield from inside theEXTRA_MESSAGESbundle, 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.
- Web Speech API (proposed as the free option). Rejected for two independent reasons:
(1)
SpeechRecognitionis a Chrome feature absent from the Android System WebView the plugin runs in; (2) even where present, it captures its owngetUserMediamicrophone (the phone), and offers no API to accept the glasses' PCM stream — there is norecognition.feed(pcm). Perfect glasses audio in hand, no door to pass it through. - Whisper on the Mac. Accurate and free, but puts a computer in the runtime path — rejected by the self-contained constraint.
- Vosk, on-device, inside the bridge. ✅ Offline, Apache-licensed, no key, and it ingests
raw 16 kHz s16le mono PCM — exactly the SDK's audio format, zero resampling. The
~40 MB English model is downloaded once into app storage on first run
(
/stt/statustransitionsdownloading → ready); thereafter inference is 100% on the phone CPU. Verified end-to-end: model loads, no crash,/sttreturns transcripts.
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:
- both sides must have ≥10 digits to fuzzy-match at all; short codes require exact equality;
- comparison is on the last 10 digits, so country-code and formatting differences still agree;
- multiple matches return
null— the system never picks among candidates.
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:
- 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. - Prefer refusing to act over guessing.
singleOrNullplus a correct fallback is strictly better thanlastOrNullplus a plausible one. - 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.
- Absolute asset paths. Vite emits
/assets/…; a packed.ehpkis not served from a domain root, so the bundle 404s and the glasses render nothing. Fix:base: './'. crossoriginon the module script. Vite always adds it. Under the packed page's opaque origin it turns a same-origin script into a failing CORS check — blank screen, no error. Fix: strip it intransformIndexHtml.- WebSocket origins need their own whitelist entries. The manifest whitelisted
http://localhost:8080; the code also opensws://localhost:8080/events. HTTP entries do not cover WebSocket schemes, andevenhub packperforms zero whitelist validation, so the package builds and installs cleanly and simply never receives another live message. This one was caught by an independent audit of the artifact, not by any test — the failure is indistinguishable from "nobody has texted recently." - Icons are portal-side assets, not manifest fields. There is no
iconkey in the pack schema, so packing succeeds with no icon at all; validation happens at upload. Generated a 24×24 monochrome glyph plus foreground/background layers. - Reboot survival. A
BOOT_COMPLETEDreceiver restarts the bridge if it was running, gated on a persisted intent flag so a deliberate "Stop" is not overridden. - Cold-start recoverability. If the glasses app launched before the bridge, the error page had no working input — a dead end reachable by simply opening things in the wrong order. It now carries an explicit mode with tap-to-retry, and the event socket reconnects with backoff.
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:
zOrderIndexis all-or-nothing. Adding a second container meant the text container needed its own unique index, or the whole page is rejected with the samerc=1that cost a day in §3.4.- A single sanitizer for two purposes. List labels must be single-line, so the sanitizer
collapsed
\s+to a space. Running body copy through the same function flattened every message boundary and rendered whole conversations as one run-on paragraph. Newlines are the only line-break control the display has; body text now uses a separate sanitizer that preserves\nand collapses only horizontal whitespace.
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:
ceil(len / width)is a lower bound, not the height. The renderer breaks at word boundaries, so a long word or URL pushes to the next line early and the true height exceeds the estimate. Under-counting clips, and clipped content is unreachable. The cost function had to simulate word wrap, not approximate it. The font is also proportional, so character count only estimates width at all; the budget is deliberately conservative, because an extra page costs one tap while clipping costs the content.- Some messages are taller than a page. Packing whole source lines cannot place a five-line message into a four-line page — it neither fits nor splits, so it clips. Worse, the packer emitted a nearly empty page (a header alone) right before it.
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_STRINGS — native 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:
EXTRA_ENABLE_FORMATTINGtakes a String ("quality"/"latency"), not a boolean.putExtra(key, true)compiles, type-checks, and is silently discarded —Bundleextras accept any type, so a wrong one is not an error, just an absence.SpeechRecognizerrequiresRECORD_AUDIOeven when audio is supplied through a pipe and the microphone is never opened. Undeclared, it failed instantly.- The fallback concealed both.
/sttwas 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.
- A silent fallback makes total failure indistinguishable from success. The system was never down and never wrong-looking; the intended path simply never executed. Any component that degrades gracefully must also report which path it took, or graceful degradation becomes undetectable degradation.
- Bundle-style APIs lose type safety at the boundary.
putExtraaccepting anything means a wrong type is a silent no-op. Where the platform can't check, verify against the SDK. javapoverandroid.jaroutperformed the documentation repeatedly — it settled the existence ofEXTRA_AUDIO_SOURCE,EXTRA_BIASING_STRINGSand the formatting constants' types when doc pages did not.- Ask what the platform provides before choosing what to bundle. Roughly 204 MB of model and a long accuracy chase existed to approximate something the OS was already offering.
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:
- Dedup on sender-time; order on receipt-time. Signal keeps
date_sentfor identity anddate_receivedfor ordering, and its conversation queries never sort by the former. One timestamp doing both jobs was the obvious design and it is wrong when two subsystems stamp the same message differently. - Never make a provider
_IDthe primary key.content://smsandcontent://mmshave independent_IDsequences, so SMS #42 and MMS #42 collide — a bug Fossify has live today. Worse,sms._idis declared withoutAUTOINCREMENT, so SQLite may recycle it after a delete and a cached id can later point at a different message. - Do not dedup on message content. Two genuinely distinct messages routinely share
(sender, minute, text)— two "Reacted to an image" notices in the same minute. On real chat data a content key does not risk false positives, it guarantees them.
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 namespaces — tel: (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:5550101234tel:5550105678
 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:
- 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.
- A search-and-replace that matches nothing reports success. It is not an error to replace zero occurrences, so the tooling gave no signal.
- 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:
- Counted, in code: the verbatim ask, the reply cadence, and how the two of you interact (who opens an exchange after a six-hour gap; who writes more, measured in characters rather than message count, since five one-word replies are not the one doing the talking). These cannot be wrong. They are also the fields the model was worst at.
- Left for a model: one field — what they talk about — the only place where being slightly vague costs nothing.
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:
- Qwen3 is a thinking model. Ask it a short question with a small token budget and the
reasoning consumes the whole budget:
contentcomes back empty. It reads exactly like a broken model and is not —/no_think, orenable_thinking: falsein the chat template arguments, fixes it. This nearly got the model declared dead. - Media placeholders and tracking links pollute extraction. "image" was among the most
frequently returned topic words, and a promotional blast whose link ended in
?t=…was once selected as the thing someone had asked — a query string contains a literal?. Both are now stripped before anything is counted.
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:
- 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.) - 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:
- Sentence-initial capitals are not proper nouns. "Friendly and casual" died because
"Friendly" was capitalised and absent from the thread. The check now exempts the first
word of a line or of a
-item; a mid-line capital is still held to account — which is where invented names and places actually appear. - Filler regenerates itself. "No recent changes mentioned", then "No specific plans
mentioned" — the model invents a new phrasing of the empty line every time, so the filter
matches the shape (
no <anything short> mentioned/specified/planned) rather than any fixed string.
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:
- "Read" is judged on the glasses, not the phone. A non-default SMS app cannot write the provider's read flags, and they would describe the wrong screen anyway — the question the dot answers is "have I seen this here". So the state is glasses-side: a per-conversation timestamp of the newest message seen, in bridge storage.
- Your own reply clears the mark — answering someone is proof you saw them.
- First run counts existing history as read. Sixty dots on install would make the mark meaningless before it ever meant anything.
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:
- 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.
- 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.
- 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 storedorseen: 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:
- Firebase App Distribution's public "invite link" is console-only. The CLI uploads
builds and manages testers and groups; the REST API's discovery document exposes
projects.groupswith aninviteLinkCountfield but no method that creates or reads a link. A pipeline can therefore automate every part of distribution except the one URL a stranger needs. Checking the discovery document settled in one request what the docs did not say. - ColorOS flags a sideloaded APK to the user as malware. On first launch the system raised "Risks detected with G2 Bridge — this app may lead to serious risks such as privacy breaches and financial loss", offering Ignore and Fix now. Nothing is wrong with the app; this is the OEM's stance on anything not from a store. It is a distribution fact worth telling testers in advance, because the alternative is a tester who taps Fix now and then reports that the app does not work.
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:
- RCS capture starts when the app does. There is no history to backfill — Google Messages keeps RCS in a database no other app can read, and notifications are the only surface (§3.19). A conversation therefore fills in from its next message onward, and a user who does not know that will reasonably conclude the app is broken.
- Android will close the app, and the user has to prevent it. Battery exemption plus locking the app in the recents list. This is the same finding as §3.27 — the system unbinds a killed listener and never rebinds it — but stated as the one action that avoids it.
- Turning the bridge off does not stop messages being collected. Capture and serving are separate concerns; the widget toggles the second, not the first. Without saying so, "stop the bridge" reads as "stop receiving", and users will leave it running out of fear.
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:
- Every page scrolls now, with
fillViewportso 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. - The width gets used. Side padding grows through
values-w600dp, and the walkthrough gains a genuine two-pane layout inlayout-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:
specialUse(types=0x40000000, verified indumpsys), which has no time budget. This is a genuine special use: a personal LAN server for a paired device. The declaration requires aPROPERTY_SPECIAL_USE_FGS_SUBTYPEproperty nested inside the<service>element — outside it, the manifest merges and the type is silently wrong.- The foreground start is guarded. A refused start is survivable; a crash is not.
onTimeout()is implemented even though it is dead code underspecialUse. If the type ever changes back, a service that does not stop itself there is killed withForegroundServiceDidNotStopInTimeException.
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 CIO — javap 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:
- A timeout is an input. The client aborting is not an edge case for a server whose client has an eight-second budget — it is the most common thing that will ever happen to it.
- Catching
Throwablecatches cancellation, and cancellation is not an error. A catch-all that responds is a catch-all that will one day respond into a closed socket. - The standard fix for the problem you assumed is not free when the assumption is wrong. I would have shipped this. It was caught by testing the change on hardware, not by review — every reviewer read the diff and approved the dispatcher hop.
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 lastOrNull — delivered 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
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).
Compile-time success ≠ runtime capability. CIO's
sslConnectorand the SDK's list API both compiled and both failed on device. The only ground truth was the hardware.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.
Read the platform's actual behavior, not its docs. The list ceiling, the
sysEventinput reroute, and the protobuf zero-drop were all undocumented or under-documented.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.
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.
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.
Deployment is a distinct failure domain, not a final step. Three defects (§3.11) — absolute asset paths,
crossorigin, and the missingws://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.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.
Sometimes the right answer to "add a switch" is to remove the difference. The dev/production origin problem dissolved once
adb forwardmade 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.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.
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.
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
enginefield to the response ended a multi-round debugging loop instantly.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?"
Where the type system stops, verify against the artefact.
Bundle.putExtraaccepts any type, so a boolean passed where a String constant was required compiled cleanly and did nothing. Decompilingandroid.jarwithjavapsettled several API questions the documentation pages could not (§3.14)."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.
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.
A wire format that drops defaults makes "true" unrepresentable. Protobuf dropping zero values (§3.4) and
kotlinx.serializationomitting 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 asfalse. Encode defaults explicitly, and treat any boolean that is meant to betrueby default as a hazard.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.
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.
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.
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.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
incomingfield that would have silently negated it (§3.17). Reading the code you just wrote confirms intent, never behaviour.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.
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.
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.
Build the fixture instead of using the person as one.
sayplusafconvertreproduced 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.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.
Verifying the wrong endpoint is worse than verifying nothing.
/stt/statusreported 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.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.
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.
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.
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.
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".
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.
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.
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.
The textbook fix for the problem you assumed costs more than the problem when the assumption is wrong. Moving handlers to
Dispatchers.IOis 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.A guard is only as sound as the expression it tests. The retry loop's
gen !== minecould never be true, because the renderer deliberately does not incrementgenfor 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.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.
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.
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
Decide whether to drop the bundled Vosk model entirely.Resolved (§3.16): kept, but made opt-in rather than automatic, and the APK reduced to 15 MB by dropping emulator-only ABIs. Deleting it outright would strand devices without Google Mobile Services and — more commonly — devices whose offline language pack was never installed.- Extend
EXTRA_BIASING_STRINGSbeyond contact names to app vocabulary ("Even Realities", "G2"), which is the remaining source of proper-noun error. - Revisit the 4x audio pacing (§3.18). It is a measured-safe constant on one device, not a understood one — the boundary between 16x and 32x is presumably an implementation detail of this recogniser build and could move. Streaming frames into the pipe as they arrive from the glasses, rather than buffering and replaying the whole utterance, would remove the question entirely and cut the added latency to zero.
- Evaluate
sherpa-onnx+ Zipformer as the fallback engine in place of Vosk: ~50% lower WER from a 42 MB model versus 204 MB, with true hotword biasing (§3.14). - Unified "recent" view merging SMS + RCS by time, with reply transport chosen per thread.
- Extend RemoteInput replies to more messaging apps (the mechanism is app-agnostic).
- Persist recent conversations so replies survive notification dismissal where possible.
- Model-bundling vs. first-run download trade-off (fully offline install).
- Optional MMS/attachment handling (notification-level view currently shows only "Image").
- Confirm the resolved recipient before sending, not only after — the strict matcher and
the
sentToreadback (§3.10) reduce the risk but still report a decision already made. - End-to-end verification of the send path, which remains the one behaviour that cannot be self-tested: exercising it delivers a real message to a real person.
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, andentrypointfields; the STT origin was removed entirely (speech recognition became on-device, §3.7); and the bridge origin resolved tohttp://localhost:8080because 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):
-
base: './'— absolute/assets/…404s in a packed.ehpk -
crossoriginstripped from the built module script - every origin whitelisted, including
ws://— HTTP entries do not cover WebSockets, andevenhub packvalidates whitelist contents not at all -
min_sdk_versionexactly matches the installed SDK (irreversible once shipped) - icons produced separately (monochrome, 24×24, fg + bg) — not manifest fields
- no unused permissions; each declared one maps to a real API call
- root-page double-tap calls
shutDownPageContainer(1) - token re-baked if it ever rotates (it is compiled into the bundle)