Skip to content

feat(servers): pair a remote server by QR code or link, with presence - #897

Draft
pocketpal-dev-team[bot] wants to merge 32 commits into
mainfrom
feature/TASK-20260903-1837
Draft

feat(servers): pair a remote server by QR code or link, with presence#897
pocketpal-dev-team[bot] wants to merge 32 commits into
mainfrom
feature/TASK-20260903-1837

Conversation

@pocketpal-dev-team

@pocketpal-dev-team pocketpal-dev-team Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Pair a remote llama.cpp server by QR code or llama:// link, and give every saved server a presence state.

  • Scan to add a server. A new "Add server" action on the Models FAB opens a sheet that reads the QR code the Llama macOS app shows, or any http(s)://host:port code, and falls back to manual entry.
  • llama:// deep links are accepted alongside pocketpal://. Registered host-scoped on Android (llama://add-server), whole-scheme on iOS, with a scheme gate in the dispatcher and in each route parser so the raw-Linking path (Android, and the iOS cold launch) is closed too.
  • Per-server presenceunknown / reachable / asleep / unreachable — probed from /health (llama.cpp) or /v1/models, shown on the server sheet with a "Check now" retry, and re-probed on foreground, on a failed completion, and by a backing-off watch while the bound server is not reachable.
  • A "waking the server" chat banner, resolved through the existing banner resolver.
  • Per-server model favourites and last-used model, persisted, with last-used written on activation rather than on a tap.
  • ServerConfig.url is canonicalised at its single write boundary.

A user-visible fix, not a refactor side effect

Canonicalisation is not tidying. A server saved with a /v1 suffix — the natural thing to paste from a client config — makes GET <url>/v1/models resolve to /v1/v1/models and 404. That was measured, not reasoned. Canonicalising at the write boundary removes the commonest cause of a "the server answered 404" pairing failure.

A trailing slash is a separate matter and the claim about it is narrower: at the wire with curl a trailing slash breaks the request, but in this app it does notnormalizeUrl already strips it at all four request-construction sites. Canonicalising the slash is identity hygiene, so a re-scan of the same address is recognised as a duplicate instead of silently creating a second record. It is not a broken-request fix, and it has its own regression test because nothing else would ever notice it.

Canonicalisation runs on updates.url before the "did this repoint?" comparison, not after the assign. The other order makes a no-op slash edit compare raw against canonical, read as a repoint, and silently drop the server's capabilities, model list and presence entry — a failure with no symptom except later staleness.

Pairing asks two questions, and never lets one answer the other

Reachability: any HTTP response of any status proves the server is there. A 401, a 404 and a 503 are all reachable; only a transport failure is unreachable. Classification happens at the wire layer, because the fetch helper throws a plain Error for both a 401 and a dead socket and the two differ only by message string.

Usability with the credentials we hold is a different question with a different answer. llama.cpp serves /v1/models to an unauthenticated caller, so a 200 on the model list proves nothing about the key. The probe therefore measures gatedness on this server: it asks a resource the server is observed to gate, once with the key and once with the key deliberately omitted, and calls the credentials verified only when the keyless control is refused with exactly 401 or 403. Every other control outcome — any 2xx, a 404, any other status, a timeout, or no control issued at all — is "unconfirmed".

The predicate that reads "not ok" instead of "401 or 403" is the bug this exists to prevent: the keyless control is capped at 5 s, so a timeout on a slow LAN server is ordinary, and reading a non-answer as a refusal pairs a key-protected server on a wrong key that then 401s at the first chat.

Three credential sentences, never two at once: verified, not verified (a key is held and the check proved nothing), and no key supplied. The last two share an internal value but must not share copy — telling a user who entered no key that "the key was not verified" invents a credential they never typed.

At most two requests to the gated resource are ever issued, both bare and status-only; no body is parsed and no capability state is written by this change.

Presence has a closed list of triggers, and one place that writes it

Six triggers and no others: pairing confirm, remote-model activation, app foreground, a completion settling with an error, an explicit Retry, and the watch tick. Everything funnels through one probe function that is single-flight per server.

The stored entry has one assign site with exactly two callers — the probe, and the promotion a successful model fetch makes. The ordering between them is deliberately asymmetric: a probe discards its own answer if a newer settle landed while it was in flight, but the promotion always writes, because an arrived HTTP response is strictly stronger evidence than a transport failure. Read symmetrically the rule inverts into a second bug, where a probe that settled unreachable first would block a later successful fetch from promoting and presence would read "offline" while the model list visibly loaded.

unknown is a first-class member of the union, never folded to "offline". A failed model fetch never demotes presence, because it cannot tell a refusal from a dead socket.

Presence is not persisted: a hydrated reachability is a claim about now that nobody checked.

Camera states, enumerated rather than assumed

The first version of this modelled one reason to fall back to manual entry — no camera hardware — and was internally consistent about it. The state it did not model, permission, is the one that fails on every fresh install: gating the camera on device presence alone mounts an unpermitted preview and renders a black rectangle with no system prompt.

The API's own state space was read out of node_modules rather than recalled. Two facts drive the design: the permission request result is two-valued and cannot report restricted; and on Android a fresh install reports denied, the same value as permanently blocked, until a request has actually been issued. The status enum therefore cannot choose copy before a request has been made. The sheet asks the OS first and only then says anything about permission, and never reads the enum.

Two distinct sentences, and hardware wins: "no camera on this device" is a false statement about a camera the user declined to share, and "allow it in Settings" is false advice on a device that has no camera. Before the request settles, neither is shown.

This change adds no camera permission or feature declaration — that file belongs to another open PR and a second edit would collide.

Evidence

Unit suite: 279 suites, 4517 passed, 2 skipped; coverage 77.29% statements. Lint 0 errors, typecheck clean.

Android device captures, chain-verified end to end: last source commit 27e6ad4d at 15:37:43 → JS bundle 15:44:07 (sha256 650060e7…) → APK 15:44:33 → installed package hashed to the same bundle before capture and re-pulled after the run → all 17 screenshots 16:08–16:10. Captures posted in a follow-up comment.

Two captured surfaces are byte-identical and deliberately so: with permission denied, the sheet routes to the manual state. It is one surface under two labels, not two states.

asleep and the waking banner are provably unreachable at this base — nothing reports the sleeping flag yet, so the accessor returns unknown for every server. They are unit-tested through the injected accessor and deferred to the follow-up that lands the flag. Not captured, and that is the reason.

Withdrawn claims — prohibitions

Several statements below were made, and some were relayed onward, before being withdrawn. The wrong versions are in circulation, so each is written as the wrong form followed by the correct one.

  1. Never write: "upstream asserts /v1/models is public." The upstream test is parametrized over /health and /models; /v1/models — the path this app calls — is not in it. Correct form: /v1/models being public is source-verified from the deny-by-default allowlist in server-http.cpp; the upstream test asserts /health and /models.

  2. Never write: "upstream asserts a completion wakes a sleeping server" without naming the endpoint. The upstream sleep test uses the native, non-streaming POST /completion. This app uses OpenAI-compatible, streaming /v1/chat/completions — a different endpoint, envelope and delivery mode. Correct form: upstream covers the native path; our own captures cover both OpenAI-compatible non-streaming and streaming.

  3. Never write: bare /props not waking a sleeping server was "measured twice on two instances." One of those runs was a router, whose bare /props answers from the router process and never reaches the sleeping child. Correct form: measured once, on one direct instance.

  4. Never write: "a trailing slash in a server url breaks requests." True at the wire with curl; false for this app, which strips it at all four request-construction sites. The /v1 suffix is the live 404.

Pending conditions and named residuals

iOS llama:// delivery is not observed. Info.plist, AppDelegate.swift and the dispatcher are read-verified only; there is no Apple hardware on the machine that produced this evidence. Inspection verifies the write, not the delivery. This needs a physical-device check not because a typo is likely, but because if one happened nothing would ever say so: a wrong entry in any of the three registration sites produces no error, no failing test and no reviewable symptom, and any one site silently voids the other two. The iOS Local Network permission grant is in the same position — the simulator never enforces it, so no simulator or CI run substitutes.

Keep three things apart, because collapsing them is easy here:

  • pod install and the iOS build are discharged by CI's build-ios job on every PR to main. No dependency changed (no package.json, yarn.lock or Podfile.lock edit), so this is a compile check.
  • iOS delivery is the pending item above. It cannot be discharged by CI.
  • Android is fully observed end to end. Green Android evidence plus a green CI build verifies the compile and the Android delivery; it does not verify iOS delivery. Different claims, different failure modes.

A slow first send after pairing. A QR-paired server gets the default connection timeout, and waking a multi-GB model may exceed it. The measured wake was 0.456 s, but on a 66 MB random-weight model — a lower bound that establishes nothing about a real one. The distinguishing symptom is not "the first send times out", which is indistinguishable from a dead or mistyped server: it is that the first send fails on the timeout and an immediate retry succeeds, because the first attempt is what woke the server. A dead server fails both times. The escape hatch already exists (per-server request timeout). The adaptive fix is deferred with the waking banner, since both are gated on the same missing flag.

Considered and rejected, recorded so it is not silently re-made: a sleep-independent "longer timeout on the first request after pairing". Rejected because it also delays the moment a user learns a genuinely dead or mistyped server is dead — much the more common first-contact outcome.

Focus timing — nothing takes focus. The confirm step no longer focuses the key field at all. It was first focused on mount, which raised the keyboard on the happy path and was implicated in three separate defects; the replacement focused it on a credentials-refused verdict, and that measured one success in five attempts on device. Code that announces an intent it does not deliver invites the next reader to tune a timing constant against a base rate that cannot answer, so it was removed rather than hardened — every available hardening is a timing guess that no test in this setup can distinguish. The field and the refusal text are adjacent children of the same scroll view, so both stay on screen and reachable; the user taps the field. A regression test fails if any focus is requested or autoFocus returns.

One open, intermittently reproduced capture failure. In one run of four, a second pairing link fired in the same session found none of the sheet's elements. It correlates with the keyboard not dismissing after Add. The leading hypothesis is the capture harness rather than the app: every sheet in the app renders the same close-button test id, and the step that closes the intervening model picker selects on exactly that id, so it can resolve to the wrong sheet and leave the picker on top. The three other runs and a by-hand reproduction all succeeded. Recorded rather than worked around.

Dead scope, recorded not dropped. "API key if encoded" has no producer: the Llama app encodes no key, token or query parameter on this path. The parser accepts one anyway, for links from elsewhere.

Architecture documentation

The design behind this change lives in the flow doc context/architecture/remote-servers.md in pocketpal-dev-team, updated alongside this work.

Generated by PocketPal Dev Team

🤖 Generated with Claude Code

https://claude.ai/code/session_014ZczPeKu6UmmW4pLGX4RXn

One parse site for the QR and the llama:// deep link, accepting the four
payload forms and rejecting every other scheme. One canonicalisation site
for a server base url: no trailing slash, no /v1 suffix, default port
elided — which also fixes a hand-entered http://host:port/v1 base, the
string the Llama desktop app displays, from 404ing every request.
probeServerReachability answers only 'did it respond', so a 401 is
reachable. probePairingTarget answers the separate question the pairing
sheet needs — usable with the credentials we hold — from the model list
plus a resource the server is measured to gate, never from a 2xx on
/v1/models, which llama.cpp serves unauthenticated. A keyless control
measures gatedness on this server rather than inheriting it from a build;
only an explicit 401/403 there yields 'authorised'.
serverPresence gets one assign site with two callers: the probe and a
successful model fetch. The probe discards its own answer when a newer
settled write landed while it was in flight, from a baseline of -Infinity
so the promotion that runs at every launch wins the race; the promotion
always writes, because an arrived response outranks a transport failure.
presenceFor folds the sleeping flag in at read time, so a four-value
answer is derived rather than stored, and unknown never reads as offline.
addServer and updateServer are the only writers of ServerConfig.url, so
canonicalising there makes every stored url canonical by construction and
every store-to-store comparison canonical for free. In updateServer it runs
before invalidatesDiscovery is computed, so a no-op trailing-slash edit no
longer reads as a repoint and silently discards caps, models and presence.

Writes only — hydrated records are left as stored, so nothing changes for a
server that already works.
Two persisted maps with one writer each, read through a single computed so
a picker row keeps array identity across unrelated renders. Favourites are
keyed by full model id and pruned by the prefix helper; last-used is keyed
by the bare server id, for which that helper is a silent no-op, so it is
pruned by the key helper instead. Both survive a url edit and drop only
with the server.
Four detached probe triggers: activating a remote model, foregrounding,
a completion settling with an error, and a backoff watch that keeps asking
while the bound server is not reachable — the thing that notices a network
coming back with no user action.

The watch reads its observable gates through one reaction and the failure
cap imperatively, since the counter is deliberately not observable. Its
timer handle stays set for the whole tick rather than only until it fires:
the probe's own presence write would otherwise re-arm through the reaction
before the failure was counted, and the cap would never close.
Registering a second scheme means a llama://hub/run or llama://chat payload
can now be delivered to us. The dispatcher therefore routes by scheme before
reaching any route branch — chat is selected by host alone and has no parser
of its own — and isHubLink and parseHubRunURL each test their own scheme,
which is what covers the raw-Linking path and the iOS cold launch, where
nothing filters the URL before the parser.

A parsed pairing link is parked and nothing else; an unrecognised one is
ignored silently.
Three sites, and any one of them silently voids the other two: the Android
intent-filter (host-scoped, as the existing ones are), the app's own
CFBundleURLTypes dict, and AppDelegate's scheme allow-list — which is what
carries the warm path, since a cold launch is already forwarded unfiltered.

The allow-list stays an exact match rather than a wildcard, because the
other registered scheme is Google Sign-In's and it relies on falling
through to .
A new sheet rather than an extension of the model picker: scan, confirm,
add, then hand off to the picker with that server's chip already selected
and its list populated.

Add is enabled for a usable server in either authorisation state and for
no other verdict, and the confirm step distinguishes three credential
states — accepted, held but unverified, and none supplied. Telling a user
who entered no key that their key was not verified would invent one.

A url matching a saved server offers that server and writes nothing. A
device with no camera opens on manual entry instead, and still pairs.
Four states plus "checking…", read through the store's derived accessors
rather than by indexing the map — a never-probed server has no entry, and
must read as not-yet-checked rather than as offline. Retry is user-
initiated, so it forwards the server's own timeout rather than the
detached clamp.
The completion itself is the wake, so there is no wake request and no
second timeout — only a label for the window between sending and the
first chunk. It resolves inside resolveBannerVariant like every other
banner, so the one-banner invariant still has a single decision site.

It precedes the snapshot branches and ignores the dismissed set: it
describes the in-flight request, not the last finished turn, and the case
it exists for is the first message to a sleeping server, where there is
no snapshot yet.
The pairing sheet chose between the scanner and manual entry from
`useCameraDevice` alone, which reports hardware and says nothing about
permission. On a fresh install the sheet always entered `scanning` and
mounted `<Camera>` with no permission and no system prompt, showing a
black rectangle with no way forward.

Gate the scanner on permission as well as hardware, request permission
when the sheet opens, and route both a refusal and absent hardware to
manual entry — with their own sentences, since "no camera on this
device" is wrong for a camera the user declined to share.
Three copy defects on the pairing sheet's own success path.

The model count always reached for the plural string, so a server with
one model read "1 models available" — on the primary success path. The
`_one` / `_other` suffixes implied a plural runtime this app does not
have, and would invite translators to fill plural forms nothing reads;
the two strings are now plainly named and chosen at the call site.

The duplicate state told the user the server was already added under
the heading "Add server". It now carries its own title.

A pairing that is refused for credentials needs the key field, so the
field is focused when the confirm step opens. Three strings with no
reader are dropped rather than sent to translators.
The initial-server lookup lived in a second effect that had to be
declared after the reset to run after it. One effect: reset, then
select, with the ordering internal to it.
The confirm step's key field holds focus, so the press that reaches Add
blurs it first. The blur handler probed unconditionally, and a probe in
flight disables Add — the press landed on a button that had just gone
disabled, and nothing was saved until the user pressed a second time.

A blur now re-probes only when the key has actually changed since the
last probe, so leaving the field untouched is a no-op. Editing the key
and leaving the field still re-probes, which is what a refused key needs.

The scroll view also keeps taps while the keyboard is up, so the press
reaches the button instead of being spent dismissing the keyboard.
A pairing link parked its request and did nothing else. The sheet is
hosted on the Models screen, so a link fired while the app sat on Chat
foregrounded the app on Chat and the sheet appeared only once the user
happened to navigate — a link whose only purpose is to land the user in
pairing.

The handler now navigates to Models, alongside the chat route which
already navigates. Hosting the sheet globally, as the hub/run route
does, was the alternative: rejected because pairing does not end at the
sheet. Adding a server hands its id straight to the remote-model picker
so the user can choose a model on it, and that picker — like the FAB
that opens the same sheet by hand — lives on Models. A global host would
either strand the user after the save or drag the picker out with it,
and would give one sheet two mount sites. hub/run has no such tie: it
resolves a repo and the file rows own the rest.
@pocketpal-dev-team

pocketpal-dev-team Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Warning

Superseded — these captures show the pre-fix UI. Do not read them as current.
The confirm step changed materially after review: the server address and a
trust notice are now visible on arrival, the keyboard no longer covers them,
and the entry steps gained navigation. A replacement capture set is being
posted; prefer the newer comment. Kept only so the change is legible.


Visual evidence — scan to add a server, and per-server presence

scan.png
confirm-usable.png
confirm-usable-single-model.png
confirm-blocked.png
add-first-press.png
duplicate.png
deeplink-cold.png
chat-before-link.png
deeplink-warm.png
presence-unknown.png
presence-reachable.png
presence-unreachable.png
manual-permission-prompt.png
manual.png
scan-permission-denied.png
manual-filled.png
manual-paired.png

Generated by PocketPal Dev Team

@pocketpal-dev-team

Copy link
Copy Markdown
Contributor Author

Independent review — REQUEST_CHANGES

Posted as a comment: GitHub will not accept a formal review verdict from the
account that opened the PR. Treat this as the review of record.

Eight role lenses (architecture, security, QA, data, UX, mobile, performance,
local-invariants), each run independently of the work that produced the change.
Findings below are grouped by shared cause, not by symptom — several
separately-reported defects turned out to be one mechanism, and patching them
individually would leave the cause in place.

Blocking

The server API key is written to the device log in plaintext.
A pairing link carrying ?key=… reaches two existing console.log sites that
print the parsed query params verbatim (src/hooks/useDeepLinking.ts:104,
src/services/DeepLinkService.ts:58). There is no transform-remove-console
and no drop_console in the release path, so this reaches logcat / os_log in
shipped builds. The test output in this branch prints a key today. CWE-532.
Neither half is wrong alone — the defect lives entirely in the join between the
new field and the existing logging.

The confirm step does not show the address the user is agreeing to trust.
autoFocus on the optional key field raises the keyboard, the sheet caps its
height, and the URL row is pushed behind the opaque header (264dp of content
against 260dp available). On the deep-link path that host is supplied by
whoever wrote the link, and up to four unauthenticated requests are already in
flight before the user confirms. Verified three ways: cropped captures, a
render harness, and measured geometry.

One cause, three reported symptoms

presenceWatchTimer is a single nullable handle doing three jobs — is
scheduled
, is running, and implicitly for which server — with no epoch and
no identity. The states it cannot represent are exactly the defects:

  • polls indefinitely when a probe cannot answer (the cap counts only
    unreachable, and removeServer never clears the active binding)
  • a pending tick probes the previous server and charges the failure to the
    new one
  • the timer chain duplicates on an app-state change — measured 1 timer → 2,
    5 probes → 10, on a trigger as ordinary as pulling down the control centre

Also blocking merge

  • The first press of Add is still swallowed on the key-protected flow
    (scan → type key → press Add): the press blurs the field, starts a re-probe,
    and the button disables underneath the press. This is the change's headline
    acceptance criterion.
  • runProbe has no generation guard — last-to-settle wins, so a stale
    usable landing after a fresh unauthorized leaves Add enabled on
    credentials the server refused.
  • The reset effect re-runs on a camera-permission change, discarding
    hand-typed input and re-running a parked link's accept path.
  • Camera permission is requested on the deep-link path, where the camera
    never mounts. iOS prompts once; two Android denials are permanent.
  • llama://host:port is iOS-only — the Android intent filter is scoped to
    a single host — and the trailing-slash form parses nowhere, contradicting the
    documented rule that a trailing slash is never disqualifying.
  • Favourites and the sleeping-server banner have no production caller. A
    persisted key is written and never read; the banner cannot render. Persisted
    schema with no reader is a migration liability.

Verified sound

Recorded so it is not re-litigated: single-writer discipline on presence; the
checkedAt asymmetry and its baseline; one-way store layering; the control
rule in the probe grammar; the closed list of presence triggers; fixture
provenance and labelling; no tracker IDs or process vocabulary in source or in
the commit messages.

Test-suite note

Two of the defects above were invisible for the same reason: a stubbed async
operation resolves inside the same act() drain as the event under test, which
collapses the in-flight window the bug lives in. Fixing the two code paths
without changing that idiom would leave the whole class unobservable.

Not verified

iOS build and pod install have not been run — this branch touches
Info.plist, AppDelegate.swift and AndroidManifest.xml, so it is a native
change. There is no Apple hardware on the machine that produced this review.
That remains an open condition on this PR, not a passed check.

Generated by PocketPal Dev Team

The verdict is now keyed on the API key it ran with, and Add reads only a
verdict for the key currently in the field. Pressing Add re-checks the typed
key and decides on the answer, so no operation the press itself starts can
disable the button underneath it, and a key typed after a scan no longer
costs the user their first press.

A probe generation retires an older request, so the newest one settles the
verdict whatever order the two return in. Camera permission is asked only
where the scanner can be reached, the confirm step shows the address and the
trust notice before the keyboard can cover them, focus moves to the key field
only when the server refused the credentials, and both entry steps now reach
each other and take a step back from the confirm.
Both deep-link log sites printed the whole params object, so the pairing
route's `key` query parameter reached logcat and os_log verbatim: the app
strips no console calls in release. They now log the scheme, the host and the
parameter names only, through one helper, so a query value added later cannot
reach a log by default.
The delivery path gated on a successful parse, but the grammar also accepts an
absolute http(s) url and a bare host:port for the scanner, so any such link the
OS handed us opened the pairing sheet. It now gates on the scheme.

The authority form also passed the whole remainder to the authority parser, so
`llama://host:9931/` was rejected even though a trailing slash is never
disqualifying elsewhere in the grammar. The authority and what follows it are
now separated, and a real path is still refused.
One nullable timer handle did three jobs — is a tick scheduled, is one
running, and which server is it for — so a pending tick probed the server the
binding had already left and charged the new one for it, and a background blip
mid-probe left two chains ticking where there had been one.

The watch is now a record naming its server, replaced rather than mutated when
the binding moves, and a tick only touches it while it still owns it. The
failure cap counts any tick that did not end reachable, not only an explicit
`unreachable`, so a probe that cannot answer at all still closes the cap, and
the watch stops as soon as the bound server is no longer configured.
…covery

A record persisted before canonicalisation existed still holds its raw url, so
the first save of such a server compared canonical against raw, read as a
repoint, and dropped the caps, model list and presence of a server that had
not moved.
The single-flight test stubbed a resolved promise, so the "second call" arrived
after the first had already finished and the assertion could not have failed.
It now holds the request open, checks the request has actually started, and
checks the entry is released afterwards so the join lasts a flight and not a
session. The retry button gains the matching check that it is inert while a
probe is already running.
@pocketpal-dev-team

pocketpal-dev-team Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

This is the current state of the pairing UI, captured under three-button navigation, and it now includes a live QR scan — the camera path, driven by a person holding a code in front of the lens. It replaces the earlier capture sets in #897 (comment) and the set this comment previously carried, both of which predate 5a217f84 and abc31d7f.

Important

Known residual, visible in these frames: the sheet's bottom action row overhangs the system navigation bar by up to 9 px. It is measured, understood, and shipping as-is. Details and the reason it cannot be fixed from inside the scroll content are in The 9 px overhang below. The controls remain operable — a raw display tap at each control's own centre was sent and did operate it, including the tap that saved the scanned server.

Note

Two different app states appear below. The 26 automated frames were shot in one run that saves servers as it goes. The four qr-scan-* frames were shot against an empty server list, because a saved address reaches the duplicate step — which would look like a successful scan while proving nothing about the confirm step. Same build throughout; only the stored data differs.

Provenance

link value
last shippable-source commit abc31d7f at 19:36:51
branch head at capture time 694f53e9 at 22:55:18. The four commits after abc31d7f touch only PairServerSheet.test.tsxtest-only. The head is newer than the bundle by design, not by staleness.
JS bundle written 23:06:08 — newer than the last shippable-source commit
APK packaged 23:06:33 — assembleE2eReleaseE2e -PreactNativeArchitectures=arm64-v8a, E2E_BUILD=true
bundle inside the built APK 4b0746ec9ee723c7f45e13ae9954d21a6db21bbd7708d6ab337ea57b5bc27d32differs from the ae38add8… bundle the previous set was shot from, so the rebuild was not a no-op
whole APK, built vs pulled off the device 68f8c7cae1fe6496a45097fc01cacb3fb2580338a9bb5fb8a17c7becd63b3cd1 — identical
whole APK, pulled before the run 68f8c7ca…3cd1 — re-pulled and re-hashed, not carried forward
whole APK, pulled after the run 68f8c7ca…3cd1 — identical (nothing swapped a build in mid-run)
device Samsung SM-S911B, Android 16, 1080×2340, density 480
navigation three-button. Inset read directly from the bar's own inset provider: 144 px (48 dp), navigation bar top at y=2196. navigation_mode also reads 0, but is recorded rather than trusted. Exactly one navigation overlay is enabled (…navbar.threebutton); every gesture overlay is off, checked explicitly.
captures written 08:25:03 – 08:45:31, each of the 30 files' mtime checked individually against the APK's, and each is newer. No frame is a --FAILED fallback.
automated spec result 13 passing, 0 failing

The app was data-cleared and force-stopped before each run, which used noReset / fullReset=false so nothing could reinstall underneath the build whose hash is recorded above.

Three controls earned their keep rather than passing decoratively:

  • E2E_BUILD=true is checked, not assumed. It has now caught two builds in this series made without it: the automation bridge compiles out and the app boots into onboarding, while every string marker and every timestamp still looks right. A hash tells you which code, not which build configuration. Three markers that exist only under the automation build (memory-snapshot-label, BENCH_RUN_MATRIX, bench-runner-screen-status) are each present exactly once in this bundle, read with grep -a on the raw bundle; two nonsense control strings are present zero times, so the search could have failed.
  • The navigation inset is read from the bar's own inset provider, not from navigation_mode. Both overlays were once enabled at the same time on this handset, which reports mode=0 with a gesture-sized 45 px bar and makes every clearance check pass vacuously. The overlay list was checked as well as the inset, and the run aborts if the bar reports zero height.
  • The stand-in servers are scanned for host paths with a byte count and a negative control. An earlier version of that scan reported zero leaks while its HTTP client was not on PATH, so it was counting an empty stream. It now asserts each of the 24 responses is non-empty (6995 bytes total) and that the pattern fires on a known-bad string, so a clean result means the check ran.

The live QR scan

Every other capture on this branch hands the pairing link straight to the parser. These four drive the path that had never run: camera → decode → parse → probe → confirm → save.

What it establishes. A code encoding http://127.0.0.1:9931/ — the payload shape the desktop app emits, trailing slash and no name — was held in front of the lens and read. The confirm step came up with the address and 3 models available. One raw display tap at the Add button's own centre (896, 2145) saved it: the sheet closed and handed off to the model picker, with pair-server-add gone from the hierarchy after that single press. The path ran twice, on two separate holds of the same code.

Canonicalisation, read off the stored server rather than the sheet. server-details-url-input holds http://127.0.0.1:9931the trailing slash does not survive into the stored address, and it did not disqualify the code either. The saved server probes Reachable on that canonical form.

One nuance visible in the frame, so it is named rather than left to be misread. The details sheet's title shows http://127.0.0.1:9931/, slash intact. That is the server's display name, not its address: a code carrying no name falls back to name: name || url (PairServerSheet.tsx:224), and that url is the string as scanned. The stored address in the field below it is canonical. Cosmetic, and only for nameless codes.

What it does NOT establish, stated because the timing looks like it should. Neither scan yields a usable decode latency. In the first, the elapsed time from scanner-up to confirm was dominated by an unknown human delay — a person had to be told, walk over, and display the code — so the figure measures the errand, not the decoder. In the second, the decode landed outside the instrumented window. No timing claim is made about the on-demand barcode model. What the scans do show is that this device decodes at all, which had never been demonstrated. That verifies this handset, with Play Services present and a working network at capture time — not the offline-LAN case, which is untouched here.

The 9 px overhang

Measured on this build, with the keyboard down:

control bottom edge centre navigation bar top overhang
confirm — Add 2205 2145 2196 9
confirm — Back 2202 2145 2196 6
confirm — Cancel 2199 2142 2196 3
scan — Enter URL manually 2205 2148 2196 9
hand entry — Add 2205 2148 2196 9
duplicate — Use 2205 2145 2196 9

Why it is not fixed by more padding. The sheet's header lives inside the scroll view's content mask but outside its dynamic-sizing budget, so the scroll viewport is permanently 90 px shorter than its content and the tail of the last row is clipped. Clearing the bar needs a bottom of at least header + bottom inset — 234 against the 225 available — and every pixel added to the padding also lengthens the sheet, so the shortfall stays exactly 90 at any padding value, on any device. It is not tunable from inside the scroll content.

Why it is not the unreachable class. The overhang is the clipped tail of the row, not the row. Each control's centre sits 48–54 px above the bar. That was checked by touch, not inferred from geometry: raw display taps (not driver-side element clicks) at the reported centres reached hand entry and saved the scanned server. The earlier defect on this surface — where the row sat under the bar and a centre tap did nothing — is gone.

The keyboard half is clean: with the key field focused and the keyboard up, Add's bottom edge is at 1179 against a keyboard top of 1314 — 135 px clear — and one press saved the server. The harness counts presses and asserts the count; it measured 1 on add-first-press and on key-required-saved, both the key-typed path, and 1 again on the scanned path. Those are three measurements of two path shapes, not three independent paths.

What is actually evidenced, and by what

This set is not self-evidencing as a whole. Two claims are; the rest rest on the hashes above, and some cannot be evidenced by a screenshot at all.

Self-evidencing — the frame itself identifies the build:

  • The trust notice on the pairing confirm step. The notice sentence alone proves nothing: it predates this work and a sibling sheet renders the identical words — visible in add-first-press and key-required-saved below, which are that sibling. The discriminator is the combination — the notice together with the address row and the Add button in one frame, which is how confirm-usable is framed. pair-server-trust, pair-server-url-label and pair-server-back were all introduced in 53860daa, with zero occurrences in the component before it.
  • Back out of the confirm step, landing on the entry step it was reached from with what was typed still in the fields.

Rests on the recorded hashes — both builds render something here:

  • The address visible on arrival with no keyboard over it. The load-bearing capture, and it needs care: the earlier confirm step also rendered an address row; it was simply pushed behind a keyboard that autoFocus raised. "An address is visible" is not by itself a discriminator.
  • The absent camera hint on the hand-entry step. entry-manual-from-scan is reached from a working camera preview and carries no "Camera access is off" line; manual is reached from a genuine denial and carries it. An absence is only evidence if it could have been a presence, so both were asserted in the same run against the same selectors — the id and the sentence — and the denied case's presence assertion is what proves the granted case's absence assertion was live rather than mistyped.
  • The QR scan frames. A live preview with a code in it is a photograph of a phone; nothing in those pixels says which build rendered them. What they evidence is the path, and that rests on the same hashes as everything else.
  • Hand entry, and the refusal verdict.
  • One press of Add saving the server. A still frame shows an end state, and "the server is in the list" is reachable with two presses too. The count is asserted or observed at the hierarchy level; the images corroborate the outcome only.
  • deeplink-cold came out near-identical to confirm-usable-single-model — the confirm step does not render the name the link carried, so the two frames are the same surface. The delivery is evidenced by the ordering (force-stop, then a bare VIEW intent with no package named, so the OS resolves the scheme from the manifest) and by the middle frame showing the app on chat with no sheet up — not by the pixels.

No visual signature at all — no screenshot can evidence these. They are covered by tests, and nothing below is a capture of them:

  • The API key kept out of the log line.
  • The presence-watch identity and owner.
  • The stale-probe ordering.

What changed since the previous set

Both of the open observations recorded against the previous set have been fixed, and both fixes are why that set had to be re-shot rather than annotated.

  1. "Camera access is off" no longer shows when the camera works (5a217f84). The hint was chosen from whether the permission request had settled rather than from whether it was granted, so anyone reaching hand entry from a working scanner was told to enable a camera that was already enabled, directly above a working "Scan a code" button. Compare entry-manual-from-scan (granted — no hint, scan route offered) with manual (denied — hint shown, no scan route). Same screen, same run, same build; the permission state is the only variable.
  2. The refusal step no longer asks for focus (abc31d7f). The key field was focused imperatively when credentials were refused; it landed roughly once in five attempts on device, which is a promise the code could not keep. The request is gone, so nothing raises the keyboard on refusal. Measured on this build: the field is unfocused and the keyboard is down on both refusal captures — asserted, not observed in passing.

Captures

The live QR scan — the code mid-read, the confirm step it produced, the single-press save, and the stored address showing the trailing slash canonicalised away.

qr-scan-preview.png

qr-scan-confirm.png

qr-scan-saved.png

qr-scan-stored-url.png

Confirm step on arrival — address and trust notice both visible, nothing behind the keyboard, Add enabled, and the whole action row above the navigation bar apart from the overhang described above.

confirm-usable.png

The keyboard half — key field focused by a tap, keyboard up, and the action row fully clear of it.

footer-confirm-above-keyboard.png

The navigation-bar half on the scanner step — this is the frame that shows the 9 px overhang in situ.

footer-scan-over-navbar.png

Entry steps and the navigation between them. entry-manual-from-scan is also the granted-camera half of the hint pair: no "Camera access is off" line, and the scan route is offered.

scan.png

entry-manual-from-scan.png

entry-scan-from-manual.png

confirm-back-to-manual.png

The key-protected path, end to end. The server refuses without a key — unfocused, no keyboard — the correct key is typed, one press of Add saves it, and it appears in the server list. Driven against a stand-in that genuinely gates on Authorization: Bearer (401 without, 200 with, wrong key 401), so the check could have failed.

key-required-refused.png

key-required-typed.png

key-required-saved.png

key-required-in-server-list.png

Refusal, duplicate, the singular model count, and the single-press save. confirm-blocked is the second refusal capture: the verdict and the key field are both readable with no keyboard over them.

confirm-blocked.png

duplicate.png

confirm-usable-single-model.png

add-first-press.png

Hand entry, and the camera-denied fallback that routes to it. manual is the denied half of the hint pair: the hint is shown and no scan route is offered.

manual-permission-prompt.png

manual.png

manual-filled.png

manual-paired.png

llama:// delivery, cold and warm, fired as a bare VIEW intent with no package named. The middle frame is the app sitting on chat with no sheet up, establishing that the third frame's sheet came from the link.

deeplink-cold.png

chat-before-link.png

deeplink-warm.png

Presence, with its check-now retry.

presence-unknown.png

presence-reachable.png

presence-unreachable.png

Generated by PocketPal Dev Team

The pairing sheet rendered its per-state action rows inside the scroll
view, so on a device with three-button navigation the Back/Cancel/Add row
and the manual-entry button sat under the system bar and would not take a
tap. Move them into the shared Sheet.Actions footer, which owns the bottom
inset, the way ServerDetailsSheet already does.
The action rows were moved into the shared Sheet.Actions footer to clear a
three-button navigation bar. Sheet sets no android_keyboardInputMode, so the
sheet pans rather than resizes, and that footer is pinned outside the only
keyboard-aware container the sheet has: with the key field focused, Add sat
732 px below the top of the keyboard and a tap at its own reported centre
landed on the keyboard. The button could not be pressed at all.

Keep the rows inside Sheet.ScrollView, which scrolls them clear of the
keyboard and puts them back under keyboardShouldPersistTaps, and take the
navigation bar inset on the scroll body instead, with the lift Sheet.Actions
applies. ServerDetailsSheet has the same footer defect; it is left alone.
The helper reads declared padding, not on-screen position. Naming them as
clearance checks invited them to be read as covering a defect they cannot
see: the sheet clips its scroll tail, so clearance is only measurable on a
device.
The manual step selected its hint from `permissionSettled`, which turns
true as soon as the permission question has an answer — including "yes".
So anyone who reached manual entry with a working camera was told
"Camera access is off. Allow it in Settings", directly above a Scan a
code button that worked.

Select on the answer rather than on its arrival, and cover both
directions: granted shows no hint and keeps the scan route, refused
shows the hint and offers no scan route.
Focusing the key field on a refused key landed once in five attempts on
device. A behaviour that rare is not a convenience, it is a promise the
code cannot keep, and the remaining ways to chase it are all timing
guesses that neither a unit test nor any run count we would do can tell
apart from what is here now.

Drop the request. The refusal and the field are both on the confirm
step, one tap apart, and nothing now raises the keyboard over them on a
sheet that pans rather than resizes.
`keyboardShouldPersistTaps` is why the action row lives inside the sheet's
scroll view, and nothing held it there: `fireEvent.press` calls `onPress`
directly and never consults the touch responder chain, so removing the prop
left every test green.
The walk took a max over every ancestor to the root, and `Sheet.Actions`
declares the same padding, so all four passed under the layout they exist to
reject. Stopping at the scroll body ties them to the container named in their
own titles.
Only the granted half of the hint selector was covered, so dropping the
settled check kept every test green while the sheet told a user their camera
access was off on the frame the system was still asking them for it.
Both existing assertions ran on the confirm step and looked only at the key
field, so an `autoFocus` on the manual step's own key field passed. Walk the
whole tree, on each step that renders a field.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant