Files
iris_x_hermes/docs/20-qr-pairing.md
T
ARIA 7a6d922d12
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s
Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
2026-08-22 22:43:13 +02:00

16 KiB
Raw Blame History

20 — QR Pairing (terminal QR + in-app scanner)

Status: implemented (M8, 2026-08-22).

Closes gap #12 in 09-pairing-security.md ("in-app QR scanner") and implements the QR branch of the §9.2 pairing flow, which the docs already promise but the code never delivered: today interactive_setup prints the pairing URL as plain text only, and the app has no iris://pair parser at all.

Two halves, independent and shippable separately:

  • A — Gateway: hermes gateway setup renders a scannable QR in the terminal encoding iris://pair?host=…&port=…&token=…. Zero new Python dependencies (pure stdlib encoder).
  • B — App: a "Scan QR" button on the Android Connect screen (CameraX + ML Kit, on-device, no Play services) that pre-fills URL + token. Not added to desktop. Plus an iris://pair deep link so any scanner (system camera app, other phones) can route the QR into the app.

20.1 Current state (what exists today)

Piece State Location
QR payload format ✅ implemented gateway-plugin/pairing.py → qr_payload(host, port, token, secure) → iris://pair?host=<lan-ip>&port=8791&secure=0&token=<64-hex>
Terminal QR rendering ❌ missing gateway-plugin/adapter.py → interactive_setup() prints the URL text only
App iris://pair parser ❌ missing app has manual URL + token entry only (ConnectScreen)
In-app camera scan ❌ missing no camera deps anywhere in app/
iris:// deep link ⚠️ partial manifest handles iris://chat/<id> only (androidApp/.../AndroidManifest.xml, MainActivity.handleDeepLink)
QR libs in hermes venv ❌ absent qrcode/segno not installed; Pillow is a hermes core dep but only renders images — the QR matrix algorithm is still needed either way

Payload size: iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex> ≈ 118 bytes → QR version 7 at EC level M (capacity 122 bytes) or v6 at L (134). The encoder must therefore support at least versions 1–8; we target 1–10.


20.2 Part A — terminal QR in interactive_setup

A1. Pure-stdlib QR encoder — gateway-plugin/qr.py (new file)

A self-contained ISO/IEC 18004 encoder, stdlib only (no qrcode, no segno, no Pillow). Scope is deliberately minimal — we only ever encode ASCII pairing URLs:

  • Mode: byte mode only (no alphanumeric/numeric/kanji paths).

  • Error correction: level M (15 %); auto-fallback to L if the payload doesn't fit at M within the version cap.

  • Versions: 1–10, auto-selected (smallest version whose capacity fits). Payloads that don't fit v10-L raise QrTooLongError (caller falls back to text-only output — see A3).

  • Components (all well-known, spec-stable algorithms):

    1. Data encoding: mode indicator 0100, 8-bit char count (8 bits for v1–9, 16 bits for v10), payload bytes, terminator, padding (0xEC/0x11 alternation).
    2. Reed–Solomon error correction over GF(256), generator polynomial 0x11D, per (version, EC level) block structure from the spec tables.
    3. Matrix placement: finder patterns + separators, timing patterns, alignment patterns (v2+), dark module, format info (BCH(15,5)), version info (v7+, BCH(18,6)), zig-zag data placement.
    4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest.
  • Public API:

    def qr_matrix(data: str) -> list[list[bool]]:
        """Encode *data* (ASCII) into a module matrix (True = dark).
        Includes the 4-module quiet zone. Raises QrTooLongError."""
    

    ~250–350 lines including the spec tables. No I/O, no globals, fully unit-testable.

A2. Terminal renderer — qr.py

def render_qr(data: str) -> str:
    """Render *data* as a terminal QR using Unicode half-blocks (▀).
    Returns '' (not an exception) when the payload is too long."""
  • Pair consecutive module rows into one character row: both dark → █, top dark → ▀, bottom dark → ▄, both light → space. (Matrix height including quiet zone is always even: 2·(17+4v)+8.)
  • Output is a single string of \n-joined lines; the caller prints it.
  • No ANSI colors, no cursor tricks — must survive less, log files, and copy-paste.

A3. Integration — adapter.py:interactive_setup()

After the existing "Pairing URL / Server URL" lines:

qr = render_qr(qr_payload(host, port, token))
if qr:
    print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
    print(qr)
else:
    print_warning("QR too large to render; use the pairing URL above.")
  • The URL text lines stay — the QR is a convenience, not a replacement (terminals without UTF-8 still work, and the text is copy-pasteable).
  • Printed on every setup run (new and existing token), consistent with the URL lines which already print the token in cleartext.
  • Security note: no new exposure — the token is already printed in the pairing URL line today; the QR is the same bytes in a different encoding, on the same operator-only stdout. (Gap #5 in the §9.7 table already documents the stdout token print.)

A4. Tests — hermes-agent/tests/gateway/test_android.py

The test file is a thin mirror importing the live gateway-plugin/ package, so new tests land there:

  1. Fixed test vectors (guard against silent algorithm drift): at least two known-good (data → matrix) pairs from public QR test vectors (e.g. the ISO 18004 annex examples / the classic KARAT v2-L vector). Assert the full matrix, not just dimensions.
  2. Round-trip via payload: qr_matrix(qr_payload(h, p, t)) has the expected version/size for a 64-hex token (17 + 4·7 = 45 modules at v7-M, +8 quiet zone).
  3. Renderer shape: every line equal length, height = half of matrix height, quiet zone renders as blank border, only the 4 block chars + space appear.
  4. QrTooLongError / render_qr → "" for a payload beyond v10-L.
  5. interactive_setup smoke: with sandboxed HERMES_HOME (conftest already does this), capture stdout and assert the QR block appears after the pairing URL line.

Run: cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py.


20.3 Part B — in-app scanner (Android only)

B1. Dependencies — app/shared/build.gradle.kts, androidMain.dependencies only

Dependency Why
androidx.camera:camera-camera2 Camera access (CameraX)
androidx.camera:camera-lifecycle Lifecycle-aware binding
androidx.camera:camera-view PreviewView for the scan surface
com.google.mlkit:barcode-scanning On-device QR decode; no Google Play services required (self-contained model)
  • Chosen over zxing-android-embedded (decided 2026-07-20): ML Kit has better accuracy/latency, is maintained by Google, and works fully on-device without GMS.
  • These go in androidMain only — the no-new-dep rule covers gateway-plugin/, and the app already carries OkHttp/SQLDelight/KCEF/etc. Desktop is untouched.
  • minSdk 29 is fine for all four (ML Kit barcode needs 21+).
  • Versions go in the existing version catalog / composeVersion-style constants at the top of the build file (follow the current pattern).

B2. Scanner activity — shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt (new)

A minimal ComponentActivity (not a Fragment, no nav graph):

  • Layout: full-screen PreviewView + overlay hint text ("Point at the QR code") + close button.

  • ImageAnalysis (STRATEGY_LATEST, YUV_420_888) → BarcodeScannerOptions(FORMAT_QR_CODE) → first result → setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw)) → finish.

  • Runtime permission: request CAMERA on launch; on denial show a message + close (the Connect screen still has manual entry).

  • Registered in shared/src/androidMain/AndroidManifest.xml (or the androidApp manifest — follow where NtfyListenerService is declared) with android:exported="false", android:theme reusing the app theme.

  • Manifest additions (androidApp manifest):

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-feature android:name="android.hardware.camera" android:required="false" />
    

    required="false" so the app stays installable on camera-less devices (the button then just reports "no camera").

B3. Platform hook — expect/actual

shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt (new):

/** Launch the QR scanner. [onResult] gets the decoded text, or null when
 *  the user cancelled / no camera / permission denied. Desktop: no-op. */
expect fun scanQrCode(onResult: (String?) -> Unit)
  • androidMain actual: ActivityResultLauncher (from the Compose LocalContext) starting QrScanActivity; maps RESULT_OK → text, everything else → null.
  • desktopMain actual: onResult(null) immediately (the button is hidden on desktop anyway — see B4; the no-op keeps the expect total).

B4. Connect screen button — ConnectScreen.kt

  • New "Scan QR" Button below the token field, rendered only when !isDesktop (iris.platform.isDesktop already exists).
  • On tap: scanQrCode { raw -> … }; on non-null raw:
    • PairLink.parse(raw) (B5) → pre-fill url and token state, clear error, and do not auto-connect — the user still taps "Test & Connect" (pairing stays an explicit act, per §10.8).
    • Parse failure → set error to "Not a pairing QR code" (don't echo the raw payload — it may contain someone else's token).
  • On null (cancel/denied): no-op, no error.
data class PairLink(val url: String, val token: String)

object PairLink {
    /** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink.
     *  Returns null on any malformation. */
    fun parse(raw: String): PairLink?
}
  • Accepts exactly scheme iris, host pair (case-insensitive scheme).
  • Required: host (non-empty), token (non-empty). port defaults to 8791 (the HTTP default, docs/19); secure defaults to 0.
  • Builds url as http(s)://<host>:<port>; validates port 1–65535.
  • URL-decodes host/token (the Python side quote()s them).
  • Pure function, no platform imports → unit-tested in jvmTest (:shared:testAndroidHostTest / :shared:desktopTest both run it): valid link, missing token, bad port, wrong scheme, wrong host, percent-encoded host, secure=1 → https, default port.

So a QR scanned by any app (phone's built-in scanner, a friend's phone) lands in Iris:

  • Manifest: extend the existing VIEW intent-filter block (or add a sibling) with <data android:scheme="iris" android:host="pair" />.
  • MainActivity.handleDeepLink: on iris://pair → PairLink.parse(uri) → stash into a mutableStateOf<PairLink?> passed into IrisApp → ConnectScreen receives it as prefillUrl/prefillToken (the params already exist). If the app is already connected, ignore (or surface in Settings later — out of scope).
  • This reuses B5's parser; add one test for the URI shape Android delivers.

20.4 Part C — doc updates (with the implementation)

Doc Change
09-pairing-security.md Gap #12 → implemented (fix the stale adapter.py:648-654 reference while at it); §9.2 QR branch no longer aspirational
10-android-app.md §10.8 "or scan QR" becomes real: scanner button + deep link, camera permission
14-milestones.md New M8 — QR pairing section (acceptance criteria below)
16-open-questions.md Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring segno
README.md Reading-order table: add row 20

20.5 Work breakdown & sequencing

Ordered so each step is independently verifiable; A and B can be interleaved (different languages, no shared surface).

# Task Verify
1 qr.py encoder + renderer (A1/A2) new unit tests green (A4.1–4.4)
2 interactive_setup integration (A3) A4.5 + manual: hermes gateway setup in a real terminal shows a scannable QR (scan with the phone's system camera app as the decoder oracle)
3 PairLink parser + jvmTest (B5) ./gradlew :shared:testAndroidHostTest
4 Deps + manifest + QrScanActivity (B1/B2) :androidApp:assembleDebug
5 PlatformQr expect/actual + Connect button (B3/B4) :androidApp:assembleDebug + :desktopApp:run (button absent, no crash)
6 iris://pair deep link (B6) ADB: adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…" → Connect screen pre-filled
7 Doc updates (Part C) —

On-device E2E (final gate, per 13-testing.md ADB workflow):

  1. hermes gateway setup on the gateway host → QR in terminal.
  2. Phone: adb shell am start -n dev.iris.app/.MainActivity → Connect → Scan QR → grant camera → point at the terminal (screenshot the QR onto a second screen if needed; the reference device is API 29 — verify CameraX works on the MIX 2S in step 4 before building the rest).
  3. Fields pre-filled → Test & Connect → chat screen.
  4. Repeat via deep link (step 6 command) with a different token.
  5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR code", fields untouched.

20.6 Acceptance criteria (M8)

  • hermes gateway setup prints a QR that a stock Android camera app decodes to exactly qr_payload(host, port, token).
  • QR encoder: fixed test vectors + size/round-trip tests green; zero new entries in the plugin's import surface (stdlib only — verifiable by ruff/import scan).
  • Android: Connect screen shows Scan QR (hidden on desktop); scanning the setup QR pre-fills URL + token; "Test & Connect" pairs.
  • Camera permission denied → graceful message, manual entry still works.
  • iris://pair deep link pre-fills the Connect screen (ADB-verified).
  • PairLink.parse unit tests cover the matrix in B5.
  • Full Python suite green: scripts/run_tests.sh (no args).
  • Docs updated per Part C; gap #12 closed.

20.7 Risks & mitigations

Risk Mitigation
Hand-rolled QR encoder has a subtle bug Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small
Terminal without UTF-8 mangles the QR URL text lines remain the primary path; QR is additive
CameraX quirks on API 29 (MIX 2S) Build the scanner activity first (task 4) and verify on-device before wiring the UI
ML Kit model size (~4 MB) Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint
Token in QR scanned by a bystander's phone Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects
secure=1 (WSS) URLs Parser already handles secure → https://; QR payload unchanged

20.8 Explicit non-goals

  • QR display in the app (showing a QR for other devices to scan) — single-device pairing today; revisit if multi-device lands.
  • hermes android pair stretch CLI (re-issue token + new QR, 09-pairing-security.md §9.2 line 48) — separate backlog item.
  • WSS cert pinning (gap #6) — orthogonal; QR carries secure=1 already, pinning is app-side.
  • iOS scanner — no iOS target (per 00-overview.md).