# APP-PLAN — Kotlin/Compose app shell (2026-07-06)

JIT plan per AGENTS.md. Follows JNI-PLAN (gate COMPLETE — the Rust side of ADR-0002 is
done and `rust/target/jniLibs/{arm64-v8a,x86_64}/libspt_mobile_link_android.so` builds on
this machine). Scope: the Android app shell — a Gradle project under `app/` that packages
the W3 `.so`, pairs with hosts via QR, browses endpoints, holds the device link in a
foreground service, and renders the interlaced endpoint view with compose + send.

## Scope (this milestone)

Deliver `app/` — a self-contained Gradle root project (Kotlin/Compose, single application
module) consuming the JNI contract documented in `rust/link-android/src/lib.rs`:

1. **Binding layer** — `com.sptmobile.link.LinkNative` (`external` declarations matching
   the `Java_com_sptmobile_link_LinkNative_*` exports exactly), `System.loadLibrary`,
   the `followNext` envelope decoder (`delta|timeout|end|error`), and a coroutine-facing
   wrapper (blocking JNI calls never touch the main thread).
2. **Pairing** — QR scan + manual-paste of the host's `QrPayload`
   (`{v, node, endpoint, token}`), persisted user-ordered paired-host list
   (rulings 3, 10).
3. **Foreground service** — one service owns every host's `LinkClient` handle +
   reconnect/backoff; instance liveness derives from link state (rulings 7, 9).
4. **Endpoint browser** — all endpoints across paired hosts' subnets, grouped by subnet,
   live registry Instance details (REQ-BROWSER-LIVE).
5. **Endpoint view** — digest snapshot + follow interlaced with history by timestamp,
   msg-id collapse of own-send echoes, compose + send (REQ-ENDPOINT-VIEW-INTERLACE;
   cursor/dedup logic already lives Rust-side in link-client's DigestView).
6. **Multi-host failover + inbound notifications** — ordered dial-list failover for
   send/list/history; inbound history rows → per-endpoint notification channels
   (REQ-MULTI-HOST-PAIRING, REQ-INBOUND-NOTIFS, REQ-DIGEST-DIRECT-ROUTE).

## Non-goals (later plans)

- **Voice pipe** (own plan): webhook receiver + Room spool + forwarder —
  REQ-VOICE-PIPE, REQ-HAZARD-DICTATION-LOSS, REQ-STAR-PER-HOST, app halves of
  REQ-VOICE-JSON-PAYLOAD / REQ-HAZARD-QUEUED-RETRY.
- Audio payload forwarding; ntfy/UnifiedPush; second-device sync; Play packaging/signing.

## Toolchain facts (this machine)

JDK 17 (Adoptium 17.0.19). SDK at `C:\Android\sdk`, cmdline-tools only (no Studio):
platform `android-34`, build-tools `34.0.0`, platform-tools, NDK r27.3, and (since W3)
`emulator` + `system-images;android-34;google_apis;x86_64` with AVD `spt-smoke`
(WHPX-accelerated; `ci/emulator-smoke.ps1` is the driver — see Q3). No system Gradle — the committed wrapper is the entry
point; `local.properties` (gitignored) points `sdk.dir` at the SDK. The app packages ONLY
`libspt_mobile_link_android.so` from `rust/target/jniLibs` (the hash-suffixed
`libiroh*.so` files there are build debris; the shim's DT_NEEDED is libc/libm/libdl only).

## Open design questions (resolve during build, smallest-commitment default)

- Q1 QR scanning: manual-paste lands first (works on emulator, unblocks everything);
  camera scan via `zxing-android-embedded` if it coexists with Compose cleanly, else
  CameraX+MLKit. Decide at W2.
- Q2 JNI threading: blocking calls wrapped in a dedicated IO dispatcher
  (`Dispatchers.IO.limitedParallelism`), one long-lived reader coroutine per follow
  stream calling `followNext(timeoutMs)`. Default: yes.
- Q3 On-device proof: **DECIDED at W3 — emulator installed, instrumented smoke green.**
  `ci/emulator-smoke.ps1` boots AVD `spt-smoke` headless (WHPX), starts a stub-backed
  `spt-mobile-host` on the workstation, rewrites the addr beacon's loopback to 10.0.2.2,
  and runs `LinkLoopbackSmokeTest` (connectTo → pair → ping → listEndpoints) on-device.
  Found + fixed at this smoke: the .so's Android-aware deps need a one-time JVM/Context
  handoff (`LinkNative.initAndroid` — ndk-context for hickory-resolver DNS,
  rustls-platform-verifier for relay TLS; the verifier's Kotlin runtime half is wired
  into Gradle from cargo metadata). Per-wave gate stays `assembleDebug` + JVM tests;
  the smoke is the on-device check before calling a wave done.
- Q4 Persistence: DataStore for the paired-host list (W2); Room arrives only when the
  history cache needs it (W4) or the voice-pipe plan.

## Waves

- **W1 — Gradle shell + binding**: `app/` root Gradle project (committed wrapper, AGP +
  Kotlin 2.x + Compose), single-activity Compose skeleton, `LinkNative` binding +
  `FollowEvent` envelope decoder with JVM unit tests, `syncJniLibs` copy task
  (`rust/target/jniLibs` → packaged jniLibs, shim `.so` only), `app` added to
  `[scan].roots`. Gate: `gradlew assembleDebug` + `gradlew test` green,
  `traceable-reqs check` exit 0.
- **W2 — pairing + host store**: QrPayload parse (Q1: paste first, scan after),
  DataStore-backed user-ordered host list, connect + ping proof surface in UI.
- **W3 — foreground service + endpoint browser**: link-owning service with
  reconnect/backoff; browse screen grouped by subnet with Instance details. Activates
  REQ-BROWSER-LIVE; REQ-PUSH-FOREGROUND-SERVICE activates doc/impl (webhook half of that
  ruling lands in the voice plan). Q3 emulator decision lands here.
- **W4 — endpoint view**: snapshot + follow loop through the service, timestamp
  interlace with historyFetch rows, msg-id collapse, compose + send. Activates
  REQ-ENDPOINT-VIEW-INTERLACE; Kotlin-side interlace/dedup unit tests add app-half
  evidence to REQ-HAZARD-DUP-ROWS.
- **W5 — multi-host failover + notifications**: ordered dial-list failover
  (send/list/history), most-direct-route digest with "pending cross-node" registry card
  fallback, inbound rows → per-endpoint notification channels. Activates
  REQ-MULTI-HOST-PAIRING, REQ-INBOUND-NOTIFS, REQ-DIGEST-DIRECT-ROUTE,
  REQ-ENDPOINT-INSTANCES (app half).

## Requirement activation (this milestone)

| REQ | Stages | Wave |
|-----|--------|------|
| REQ-BROWSER-LIVE | doc, impl, unit — DONE W3 (Rust host half carries impl/unit; Kotlin halves tagged, deferred per blocker) | W3 |
| REQ-PUSH-FOREGROUND-SERVICE | doc only activated at W3 (impl/unit evidence Kotlin-only — tagged, deferred per blocker; webhook half = voice plan) | W3 |
| REQ-ENDPOINT-VIEW-INTERLACE | doc only activated at W4 (impl/unit evidence Kotlin-only — tagged, deferred per blocker; Kotlin app-half REQ-HAZARD-DUP-ROWS unit tags likewise in-tree awaiting scanner) | W4 |
| REQ-MULTI-HOST-PAIRING | doc only activated at W5 (impl/unit evidence Kotlin-only — DialList walk, history union, PairedHostList + tests; tagged, deferred per blocker) | W5 |
| REQ-INBOUND-NOTIFS | doc, impl — DONE W5 (host/JNI half carries impl: route_inbound, SpoolDrain serve; Kotlin SpoolInbox/InboundNotifier/drain loop tagged, deferred) | W5 |
| REQ-DIGEST-DIRECT-ROUTE | doc, impl — DONE W5 (host half carries impl: digest_snapshot LOCAL-only; Kotlin DigestRouter/parseLocalIds tagged, deferred) | W5 |
| REQ-ENDPOINT-INSTANCES | doc, impl — DONE W5 (host/proto half carries impl: per-host bind_gateway, Ping liveness; Kotlin HostLinkState/registry card tagged, deferred) | W5 |

(`int` for the app milestone rides the Q3 instrumented smoke if the emulator lands;
otherwise stays with the existing Rust cross-process int test.)

**Activation blocker (found at W2):** the traceable-reqs scanner has no `.kt` support —
Kotlin evidence is silently invisible (docs/TRACEABILITY.md "Known scanner gap";
upstream ask = BigscreenVR/traceable-reqs#9). W3+ activations whose `impl`/`unit`
evidence is Kotlin-only would false-fail `check` — before each activation, re-check the
gap; if still open, activate `doc` only and record the deferred stages here.

## Gate

Per wave: `gradlew assembleDebug` + `gradlew test` green in `app/`; `cargo test` in
`rust/` and `host/` untouched-green; `traceable-reqs check` exit 0.
