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:
-
base: './'in the Vite config -
crossoriginstripped from the built module script - every origin whitelisted, including
ws:// -
min_sdk_versionexactly matches the installed SDK — irreversible once shipped - icons produced separately (monochrome, 24 × 24, fg + bg); they are portal upload
assets, not manifest fields, so
packsucceeds without them - no unused permissions; each declared one maps to a real API call
- root-page double-tap calls
shutDownPageContainer(1)— mode 0 or a custom confirm is rejected - no secrets in the bundle: an
.ehpkis extractable -
package_idis permanent once uploaded
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 TLS — sslConnector 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:
- Offer an "if you can't tell, say so" escape hatch and a small model takes it every time — an abstention that costs nothing is the easiest token to predict.
- Give it two worked examples and it will answer an unrelated input with the example's own content, fluently and confidently.
- Require every content word in the output to appear in the source (matched on stems). This kills fabrication outright and is worth doing regardless — but it cannot make a small model interesting, only honest.
- A thinking model (Qwen3 and kin) with a small token budget spends it all on reasoning and
returns an empty
content. That looks exactly like a broken model./no_think, orenable_thinking: false, is the difference between "unusable" and "fine".
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:
- llama.cpp's Metal path miscomputed Granite 4.0's hybrid-Mamba blocks — every answer was a
run of
@. The same build on CPU was correct. An eval rig can silently corrupt exactly the model you are judging; sanity-check a new architecture on more than one backend before scoring it. - Ground only the concrete tokens (capitalised words, numbers). Grounding every word rejects "casual" and "parenting" — correct abstractions no message contains verbatim — and quietly converts "the model is right" into "the filter says it's wrong".
- On Android, AGP's debug variant hands CMake
CMAKE_BUILD_TYPE=Debug, so a fetched ggml builds at-O0— inference runs ~20× slow and reads as "phones can't do this". ForceReleaseand set-march=armv8.2-a+dotprod+i8mmviaadd_compile_options()(appending toCMAKE_CXX_FLAGSas one string makes clang parse-O3 -march=…as a single argument). - With all three fixed, the same task the 350M failed, a 1B did usefully: ~15–30 s per conversation on a phone CPU, model opened and closed per pass so the gigabyte of RSS is transient.
- Want more than a phrase? Raise the decode budget, not the question count. Prefill is where a phone's seconds go, and it costs the same for 28 output tokens as for 200. One prompt asking for four labelled sections returns ~10× the content for ~1.5× the time; four separate questions pay for the transcript four times. Parse and ground each section independently so one invented line costs itself, exempt sentence-initial capitals from the proper-noun check ("Friendly and casual" is not a fabricated name), and filter the empty line the model keeps rephrasing ("No recent changes mentioned", "No specific plans mentioned") by shape, not by string.
- A model-written cache needs a freshness clock. Store when the model read the thread and re-queue whenever the conversation's newest message is younger — "computed once, kept forever" is how a profile of a living conversation quietly becomes fiction.
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"
}
- 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.
- On-device HMR —
npm run dev+npm run qr, scan from the Even app. Requiresvite.config.tswithserver: { 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 — useadb reverse tcp:5173 tcp:5173and point the QR athttp://localhost:5173; it runs over USB and ignores the network entirely. - 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.