Even Realities G2 · personal build

Building for the G2

The same material as a practitioner's guide, indexed by symptom — because on this stack almost nothing produces an error message.

Building for the Even Realities G2

A practitioner's guide to the Even Hub platform, written from one shipped app.

Every entry here cost real debugging time. Almost none of it produced an error message — that is the defining property of this stack, and the reason this document is organised by symptom rather than by topic. If something is silently wrong, start with the tables.

The full story of how each one was found is in DEVELOPMENT-PAPER.md; the § column points into it.


The platform in one page

The glasses are a display and input terminal over BLE. All logic runs on the phone. You never draw pixels — you create containers (text, list, image) and update them through the SDK bridge. Your code runs as a web app inside the Even app's WebView, on the phone.

Canvas 576 × 288 per eye, origin top-left
Colour 4-bit greyscale — 16 levels of green. No colour, ever
Containers max 4 image + 8 other per page; exactly one with isEventCapture: 1
Text 1,000 chars on create/rebuild, 2,000 on upgrade; left/top align only; one font, no sizing
Lists ≤14 items × 46 chars in practice (docs say 20 × 64); no in-place update — full rebuild
Images ≤288 × 144, greyscale, sequential BLE transfer — frequent updates freeze the link
Input tap, double-tap, swipe up/down; optional R1 ring emits the same events. No hold
Audio 4-mic array in, PCM s16le 16 kHz mono. No audio out
Link BLE, 10–30 KB/s
Lifecycle Android may kill the WebView with no warning and no resume event

Two consequences worth internalising before designing anything:

A page render is a network round trip. rebuildPageContainer over BLE takes hundreds of milliseconds. Anything time-sensitive — opening a microphone, starting a timer — must not wait on it.

Controls and content compete for the same 288 pixels. Each button row costs ~40 px, about 1.4 lines of text. With three buttons you have roughly four lines left. Adding one convenience control can double a document's page count. This is arithmetic, not taste, and it should be done before adding the control.


Gotchas — Even Hub / glasses

Symptom Cause Fix §
Top button of every screen is dead; everything else works Protobuf drops all zero values, so a selected index of 0 arrives as undefined — exactly like CLICK_EVENT, which is also 0. A rebuild resets selection to row 0, so row 0 is where every screen's first button sits. currentSelectItemIndex ?? 0 3.4
App navigates itself the instant a page appears The host emits a post-render "selection settled" event that is byte-identical to a real tap Ignore taps for ~600 ms after each render 3.4
list create rc=1 zOrderIndex is required on list containers, and it is all-or-nothing across the page Set it everywhere 3.4
list create rc=1 n=20 max=64 The documented 20 × 64 list is rejected on device ≤14 rows × 46 chars 3.4
Taps stop arriving once the mic is on Input reroutes to sysEvent — it is no longer listEvent Detect gestures envelope-agnostically 3.8
textContainerUpgrade silently does nothing Needs containerID and containerName to both match the created container Share constants; never inline the strings 3.4
No input events at all Zero containers with isEventCapture: 1 — exactly one is required 3.4
SDK calls no-op at startup Called before await waitForEvenAppBridge() Make it the first line of app logic
Long text clipped into unreachable space Pagination by characters underestimates height, and text containers cannot be scrolled programmatically Pre-wrap into rendered lines, then pack exactly what fits 3.13
A conversation renders as one run-on paragraph The list-label sanitizer collapses newlines along with other whitespace Separate sanitizer for body text 3.6
A screen with no way out A view rendered with zero selectable rows Make the renderer guarantee a back control 3.12
Selection can't be restored on "back" The selected index is report-only — no API sets it, and every rebuild resets it Re-anchor the list window so the item you opened is row 0 4
Glyphs vanish Only the built-in LVGL font renders; unknown glyphs are dropped silently. No emoji ASCII plus a tested safe set (━ ─ █ ▲ ▼ ● ○ ■ □ ★ ☆ │ >)
Second line of a two-line list label collides with the next item A \n renders, but items advance a fixed 40px and the group is centred in its container — measured from two first-item offsets, both solving to exactly 40. No itemHeight exists Spend two real items per entry (name row + detail row) carrying the same action 3.25
State lost when the user looks away Android reclaims the WebView with no resume event Persist on every change via the bridge storage API; rebuild from it at startup
The bridge "times out" and stays broken even after it comes back A retry loop that re-arms and launches an unawaited attempt which re-arms again — the number of chains doubles per failure until retries alone exhaust the WebView's ~6 sockets per origin, starving the user's own request One module-level timer, cleared before every re-arm; keep the backoff outside the call chain 3.36
A guard on a generation counter never fires The renderer deliberately skips gen++ when the screen it is asked to draw is identical to the one already up — and an error screen replacing an identical error screen is exactly that Count what you mean: a separate navigation counter, incremented unconditionally 3.36
A new message never appears in the conversation you have open The "screen unchanged, skip the rebuild" check compared only the row labels; the fixed rail and a constant page count meant the new body was fetched and then discarded Include the body in the identity test, and textContainerUpgrade when only it changed 3.35
A background refresh cancels the tap the user just made The background repaint bumped the same counter a real navigation does Pass the repaint a flag; a repaint is not a navigation 3.35

Gotchas — packaging

These are the cruellest ones: none can surface against a dev server. The app works all the way through development and fails only once packaged.

Symptom Cause §
Blank glasses from a packed .ehpk that worked in dev base: './' missing — absolute /assets/… 404s under the packed origin 3.11
Blank glasses, no error anywhere Vite adds crossorigin to the module script; under the packed page's opaque origin that turns a same-origin script into a failing CORS check 3.11
Messages display but never update WebSocket origins need their own whitelist entries — http:// does not cover ws://, and evenhub pack validates whitelist contents not at all 3.11
Submission rejected name contains "Even"; unused permissions declared; root-page double-tap not shutDownPageContainer(1)

Checklist before packing:

Gotchas — reading messages (SMS, MMS, RCS)

Most of these are undocumented or actively contradicted by the javadoc. They were established from AOSP source and from dumping the device's own state.

Symptom Cause Fix §
Every MMS appears to be from January 1970 SMS date is milliseconds; MMS date is seconds. Both javadocs say only INTEGER (long). AOSP's own getConversations() normalises the two branches of its UNION with multipliers 1 and 1000 Multiply MMS by 1000 on read 3.19
A cached message id silently points at a different message sms._id is INTEGER PRIMARY KEY without AUTOINCREMENT, so SQLite may recycle a rowid after a delete. pdu._id, threads._id and canonical address ids all do have it Pair the id with the date, or use a synthetic key 3.19
SMS #42 and MMS #42 are the same row in your store content://sms and content://mms have independent _ID sequences Synthetic PK + (source, source_id) natural key 3.19
A failed or queued send is never visible A non-default app does not read the tables — the provider substitutes sms_restricted / pdu_restricted, filtered to inbox and sent only. Drafts, outbox, failed and queued are invisible Own send state locally; the provider will never tell you 3.19
An SMS query returns an empty cursor for no reason A query containing a subquery is rejected — and MmsProvider throws while SmsProvider catches and returns null Null-check every SMS cursor
A thread you had disappears thread_id is never reused, but the thread row is deleted when its last message is Treat thread_id as a stable-but-mortal foreign key, not as identity 3.19
Two contacts merge into one conversation Android matches numeric addresses on the last 7 digits, right-to-left, via a native SQLite function — and both the digit count and the strict/loose flag are OEM-configurable. It never normalises to E.164 Key on your own namespaced identity; never suffix-match a short code against a full number 3.19
An alphanumeric sender ID gets mangled into digits libphonenumber converts anything vanity-shaped; short codes are explicitly out of PhoneNumberUtil's scope; parse() throws without a default region Treat canonicalisation as a tagged union and normalise only real numbers 3.19
A long message never matches its other copy Notification bodies are hard-truncated at 1024 characters by Notification.safeCharSequence Allow prefix matching only at exactly 1024 chars; require exact equality below it 3.19
RCS history stops at ~25 messages MessagingStyle.MAXIMUM_RETAINED_MESSAGES = 25 — that is the whole backlog a notification can carry Ingest every message in every bundle; it is also what makes outages self-heal 3.20
You want the sender's phone number from an RCS notification Google Messages puts it in extra_im_notification_participant_normalized_destination — E.164 for contacts, raw digits for short codes. Absent for groups Use its presence as your confident/uncertain boundary 3.19

Gotchas — NotificationListenerService as a data source

If your app depends on notifications for data it cannot get anywhere else, these decide what your product can honestly claim.

Symptom Cause Fix §
Messages that arrived while you were dead never show up Notifications posted while unbound are never replayed. getActiveNotifications() on connect returns only what is currently showing — a notification posted and dismissed during an outage is unrecoverable, and notification history is a system-only API Re-scan on every onListenerConnected(); accept the hole, or say so 3.20
Your listener dies and never comes back Listeners are bound BIND_NOT_PERCEPTIBLE on purpose (assistants are not), so LMKD is invited to kill them. The system then attempts exactly one rebind, 10 s later, and gives up until reboot or a package change Foreground service to raise oom_adj; component-enabled toggle as a watchdog 3.20
requestRebind() does nothing It early-returns unless the component is in mSnoozing, which only happens after an explicit requestUnbind(). Widespread folklore has this backwards Toggle the component's enabled state (DONT_KILL_APP), then requestRebind(). Verified on ColorOS 15 after a full force-stop: open the app → system logs binding:service connected → the reconnect backfill sweeps the shade. Run it at your natural recovery moments (service start, activity resume), gated on a connected flag so a live binding is never churned 3.27
You never noticed the outage onListenerDisconnected() is called only from onDestroy(), which does not run on SIGKILL The next onListenerConnected() is your only signal 3.20
The permission says enabled but nothing arrives A granted notification-access permission is not a liveness check — this is the decade-old "stops working after an app update" report 3.20
The same message arrives twice Duplicate onNotificationPosted for one key is a known open AOSP bug, observed with Google Messages Dedup by notification key, never assume one callback per message 3.20
OTP content is redacted from your listener Android 15 redacts OTP notifications from untrusted listeners A CompanionDeviceManager association confers trusted status — the natural model for a companion app

Gotchas — shipping it to other people

Symptom Cause Fix §
Renaming the app loses everything An applicationId change makes a new app to Android: new data dir, no permissions, no notification-listener binding run-as tar out of the old app and into the new one carries db/prefs/files; permissions and notification access must be re-granted by hand. Rename before anyone else installs — it is permanent after 3.28
New build's routes 404 after a rename The old app restarted itself and took the port first pm disable-user the old identity, don't just force-stop it 3.28
A token compiled into a distributed package An .ehpk/APK is an extractable zip; a shared constant is not a secret Per-install token + a pairing handshake gated on a visible consent dialog 3.28
"Loopback is private, so auth is unnecessary" Every app on the phone shares localhost. Any app with INTERNET can reach 127.0.0.1 Keep the token. Loopback keeps other devices out; the token keeps other apps out 3.28
Automating Firebase App Distribution stops at the invite link The CLI and REST API cover builds, testers and groups; the public invite link is console-only (inviteLinkCount exists on the Group schema, no method mints it) Generate it once in the console; automate everything else 3.28
Testers report the OS calling your app malware ColorOS (and kin) flag any sideloaded APK: "Risks detected… privacy breaches and financial loss", with a Fix now button Nothing to fix — warn testers in the release notes before they tap it 3.28

| Content unreachable after unfolding a foldable | The unfolded inner screen is shorter than the folded outer one (763dp vs 805dp on a Find N6) while being twice as wide — a page sized to fit folded clips when opened | Make pages scroll with fillViewport; use values-w600dp / layout-w600dp for the extra width | 3.30 | | A drawing pushes content off only on the big screen | Aspect-locked art scales with width, so extra width becomes extra height — on a foldable that is height you just lost | Cap art height, not just width | 3.30 |

Gotchas — Android companion app

Relevant if your plugin talks to a bridge app on the phone.

Symptom Cause Fix §
Server crashes at startup Ktor CIO cannot serve TLSsslConnector throws at runtime. A WebView rejects self-signed certs anyway Plaintext HTTP bound to loopback 3.2
A boolean field never arrives at the client kotlinx.serialization omits any property equal to its default encodeDefaults = true 3.17
Your own sent messages appear under the other person's name MessagingStyle marks own messages by omitting the sender; filling that gap with the notification title names the wrong person Treat an absent sender as self — but filter lock-redacted copies first, since those also lack one 3.17
A group chat appears as several unrelated people Google Messages posts one notification per speaker Group on EXTRA_CONVERSATION_TITLE; EXTRA_TITLE is "A, B, C: latest sender" and changes 3.17
The inbox duplicates everything on every rescan StatusBarNotification.postTime changes on each re-post Dedup on the message's own time 3.6
A reply reaches the wrong conversation Loose phone-number suffix matching, plus picking the most recent candidate Strict match, refuse on ambiguity, and display the resolved recipient 3.10
Transcripts come back unpunctuated EXTRA_ENABLE_FORMATTING takes a String, not a boolean. putExtra accepts anything, so a wrong type is a silent no-op Pass the String constant 3.14
Speech recognition fails on every call, invisibly A fallback engine answered plausibly while the primary never ran once Report which engine served each result 3.14
Dictation loses everything after the first pause Piped audio delivered ~100× faster than real time makes the endpointer's timers meaningless Pace the pipe — 4× real time, measured safe 3.18
Every utterance loses its last word EOF on the pipe is not the "speaker stopped" signal Append ~700 ms of trailing silence 3.18
Raising the endpointer's silence thresholds changes nothing Those extras are advisory and ignored by this recogniser EXTRA_SEGMENTED_SESSION keyed to the audio source 3.18
RECORD_AUDIO demanded even though you never open the mic SpeechRecognizer requires it regardless of EXTRA_AUDIO_SOURCE Declare it, and explain it in the manifest 3.14
A picker assigns the wrong person after searching ArrayAdapter's built-in Filter reports positions in its own filtered space, not the underlying data's Filter the display list and the id list together, by hand 3.21
Re-inserting on conflict silently deletes child rows Room's OnConflictStrategy.REPLACE compiles to INSERT OR REPLACE, which deletes then inserts — FK cascades fire on that delete and cannot be disabled, and the rowid changes @Upsert, or IGNORE-then-UPDATE in a transaction
A string constant looks empty but is not A literal can contain an invisible control character, which reads as empty in every inspection and makes a search-and-replace silently match nothing Assert on bytes; write unprintables as \uXXXX escapes 3.21
A dataSync foreground service dies every day and will not restart API 35+ caps dataSync at six cumulative hours per 24; past the cap the system stops it and refuses to start it again until the app is opened by hand specialUse, with the PROPERTY_SPECIAL_USE_FGS_SUBTYPE property nested inside <service> 3.32
The process crashes on every restart attempt startForeground() throws when refused, and it was called from onCreate() — so a refusal takes the process down instead of failing Wrap it; a refused start is survivable, implement onTimeout() too 3.32
The app is alive but the server is unreachable START_STICKY is a request an OEM may ignore. The process can survive (the system keeps the notification listener bound) while the server is gone, and nothing on the phone looks wrong An AlarmManager watchdog — it lives in the system, so it fires whether or not your app does — gated on the user's own switch 3.32
The server accepts connections and never answers any of them A client abort cancels the call coroutine; StatusPages' exception<Throwable> catches the CancellationException and responds to a closed connection, which throws again out of the handler and stops the CIO engine. Three concurrent aborts is enough Rethrow CancellationException; never answer a cancelled call 3.35
withContext(Dispatchers.IO) in a Ktor handler makes things worse It adds a suspension point inside the call coroutine for a client abort to land on — see the row above. callGroupSize/connectionGroupSize/workerGroupSize are inherited but never read by CIO (javap the jar) Leave short blocking work on the call thread; move genuinely long work to a plain background thread 3.35
A dead WebSocket client is never cleaned up A handler that only ever sends never learns the peer is gone, and Ktor's ping period defaults to off install(WebSockets) { pingPeriodMillis = 20_000; timeoutMillis = 15_000 } 3.35
Every incremental sync re-reads more rows than the last A high-water mark derived from stored rows stops advancing when those rows are written under a different source label — here, SMS captured from its notification first Record how far the sync read; do not infer it afterwards 3.34
Messages vanish silently under write pressure SQLiteDatabase.insert() returns -1 on failure rather than throwing, so a caller that assumes success advances past the row Check the return value 3.34
Reads block for seconds while writes are in flight Without WAL a writer excludes every reader on every other connection — and several components each opening their own SQLiteOpenHelper means several connections to one file setWriteAheadLoggingEnabled(true) in init, one helper per process, beginTransactionNonExclusive 3.34
A sync takes tens of seconds Every statement outside a transaction is its own transaction and its own flush; and contact-name lookups are IPC into another process, held inside the write lock Batch into chunked transactions; resolve names before the transaction opens 3.34
The battery exemption you granted stops applying It is keyed on application id. A rename leaves it attached to the old package, and nothing warns you — your setup screen correctly reports it missing to a user who did grant it Re-verify everything granted through a system dialog after any rename; dumpsys deviceidle whitelist 3.37
The OEM is trying to freeze your app ColorOS logs its intent plainly: OplusHansManager … cannot transition from R to M, importance=fg-service means only the foreground service is holding it off. A frozen process still holds its listening socket, so clients connect and then wait Get on the battery whitelist; read the OEM's own log lines 3.37

Patterns that worked

Split layout: a List for controls, a Text container for content. Text containers have no selection model, so they cannot host buttons; lists cannot render prose. Up to 8 non-image containers are allowed per page and only one captures events, so pair them — a list of buttons that takes input, a text container below it that renders the body. Page turns use textContainerUpgrade, which neither rebuilds the page nor resets the selection.

One gesture, one meaning, enforced structurally. Swipe moves the selection, tap activates it, double-tap goes back — everywhere, in one branch before any view-specific logic, so it cannot silently re-acquire exceptions. A gesture that means different things in different places is a quiz. Every other action becomes a visible, selectable, labelled row.

Paginate in rendered lines, not characters. Simulate the word wrap, count the lines, pack exactly what fits. "Paginate at 400–500 characters" is reasonable-sounding advice that fails for conversational text, which is short in characters and tall in lines. There is no metrics API — measure line height and characters-per-line off actual screenshots.

One origin everywhere. The plugin's WebView runs on the phone, so http://localhost:PORT is correct in production. Add adb forward tcp:PORT tcp:PORT and it is also correct from the desktop simulator. No dev/prod switch, therefore no untested shipped path.

Make the dangerous state unrepresentable, not guarded. When merging two identity spaces, put confident and unconfident keys in separate namespaces (tel: / short: / group: / name:). A rule like "only merge when sure" has to be remembered at every call site forever; namespaced keys make the wrong merge impossible because the strings cannot be equal. The same move applies anywhere a heuristic decides whether two things are the same thing.

Judge "read" on the display that shows it, and make persistence observable. Unread state for a glasses list belongs on the glasses — the phone's read flags describe the wrong screen (and a non-default SMS app cannot write them anyway). Seed first-run history as read so install does not produce sixty dots, but spare anything that just arrived: the message from five minutes ago is what the mark exists for, and a blanket seed swallows it. Then make the store's survival visible — one boot line, stored vs seeded — because a state store that silently fails to persist re-seeds every launch and disables the feature forever while looking exactly like a quiet day.

Name things on screen instead of marking them. A leading > to indicate direction disappears on every wrapped line, and at 42 characters most lines wrap. Prefixing each line with a short name survives wrapping.

Give a small model only the fields where vagueness is free — then measure whether it earned the download. Asked for a person's disposition, a 350M model returned "optimistic and enthusiastic", "relaxed and enjoying", "calm and content": not wrong, but constant, which carries no information while looking like it does. Asked to extract a question that was in the text verbatim, it paraphrased the question back. Counting beat it on every field that had a right answer — who opens a conversation, who writes more, when they reply, what they actually asked. Three prompt shapes over eight real threads produced at best two useful lines, against a 218 MB download. Symptoms worth recognising, because each one looks like something else:

Before declaring a model class unfit, separate its failures from your harness's. The paragraph above was half wrong, and the corrections are their own gotcha list:


Toolchain and the three test loops

Node ^20 || >=22 (18 is not supported). npm i -g @evenrealities/evenhub-cli @evenrealities/evenhub-simulator; the binary is evenhub (alias eh). Pin the SDK exactly — it is pre-1.0: npm i --save-exact @evenrealities/even_hub_sdk@latest.

{
  "dev":   "vite",
  "sim":   "evenhub-simulator http://localhost:5173",
  "qr":    "evenhub qr --url http://$(ipconfig getifaddr en0):5173",
  "build": "vite build",
  "pack":  "evenhub pack app.json dist -o app.ehpk"
}
  1. Simulator — fastest loop, no hardware. It lies in places: it allows 6 image containers where the device allows 4, and it is not pixel-accurate. Anything shippable gets tested on device.
  2. On-device HMRnpm run dev + npm run qr, scan from the Even app. Requires vite.config.ts with server: { host: true, hmr: { host: '<lan-ip>' } } or the QR loads a blank page. If the phone is not on the same Wi-Fi — check, do not assume — use adb reverse tcp:5173 tcp:5173 and point the QR at http://localhost:5173; it runs over USB and ignores the network entirely.
  3. Packed .ehpk — the only loop that exercises the packaging failures above. Do it before you believe anything works.

Known quirk: @evenrealities/evenhub-simulator loses its .bin symlink on install. If the binary goes missing, npm rebuild @evenrealities/evenhub-simulator.


One more silent one, from the toolchain

The site generator fed markdown to npx marked over a stdin pipe and read the HTML back over a stdout pipe. On a 105 KB document it returned exit code 0, an empty stderr, and 63 KB of the 120 KB output — node exited before its piped stdout flushed. The hosted paper served for a day with its last five sections missing, and the heading count in the build log looked plausible enough that nothing flagged it. Two fixes, both worth stealing: use marked's own -i/-o file arguments (no pipes anywhere), and assert that the HTML is at least as long as the markdown — tags only add, so anything smaller is loss, and loss with exit code 0 is the kind this platform teaches you to expect. A cousin from the same build: a page script that calls an undefined stop() does not throw — it resolves to window.stop(), which halts page loading instead of your animation. Name demo controls something the window has not already claimed.

Two habits that found most of this

Decompile rather than read the docs. javap over android.jar settled more API questions than the documentation pages did — whether an extra exists, what type a constant is, which listener callbacks are available. Where an API takes Bundle extras there is no type checking at all, so a wrong type is a silent no-op that compiles cleanly.

Record what the platform actually did, before theorising about it. Dumping the device's real notification structure produced a correct filter in one pass, after synthetic tests had passed while missing two entire categories. Recording a speech recogniser's callback sequence eliminated three of four competing hypotheses in a single request and pointed at a cause none of them contained. Both replaced multiple rounds of guessing, and in the second case two shipped non-fixes.

A corollary that cost two releases: verifying an adjacent endpoint is worse than verifying nothing. A status endpoint reported speech healthy throughout, because it checked whether an engine existed and never exercised recognition. A green check on the wrong path reads exactly like a green check on the right one.