Files
ai-app/docs/RUST.md
T
irisandClaude Opus 5 4ccfda6b8e Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00

50 KiB
Raw Blame History

Moving the app to Rust

Working document for the port Iris asked for on 2026-09-04: the phone app in pure Rust, one UI framework shared with a desktop app, at full feature parity and giving up nothing native -- performance especially. Her constraints: no Dioxus and nothing that draws through a WebView; no UI DSL (which ruled out Makepad and Slint); the result stays lightweight; platform-specific pieces are fine to maintain; reimplementing a framework piece from scratch where it does not fit is fine; effort and elapsed time do not matter, long-term robustness does.

The framework question is closed. Iris chose her own library, iris, over Masonry on 2026-09-05. The bake-off that got there, and the twelve experiments that proved it on a device, are summarised in "What the experiments settled" below rather than kept at length. What is left in this file is the plan for the rest of the app and the findings that outlive the tasks that produced them.

Decisions get a date and a reason here, the way PLAN.md does.

Keep this file current as you work

This file is the handoff, and it is meant to let a session be cleared. Write each result into it as you get it, not at the end: the box ticked or the reason it could not be, the measurement with its number, the decision with its date and what it rejected, and anything that cost time to find out. Then a session that has filled its context can be cleared and the next one can pick up from this file alone, which is much cheaper than carrying a long conversation or re-deriving what was already measured.

Two things that follow. Write for somebody who was not here -- name the command, the file and the number rather than "the fix" or "the earlier run". And write the failures and the dead ends too: "Venus is blocked by the emulator, not by Mesa" and "the present mode was not the cause" are worth as much as the successes, because they are what stops the next session spending an afternoon on them again.

And delete a plan once it has been carried out (Iris, 2026-09-08: "remove everything that's already done and decided... many with checkboxes already ticked off that just fill up context"). A ticked box has done its job; a finished experiment is worth one line saying what it settled, not the log of settling it. Currency means this file says where things are, not how they got here. What survives a prune is what cannot be cheaply re-derived: measurements, dead ends, invariants and their reasons.

Where things stand (2026-09-08)

  • The framework is decided and built on. iris draws the transcript screen on the desktop, on this checkout's emulator and on Iris's phone.
  • P0 (the phone benchmark gate) passed -- both apps ran on her own phone and the reports are under docs/bench/.
  • P1 (session screen parity) is the current work, and is where the next session should start. Its box below has the state.
  • The repository was reorganised on 2026-09-08: the port is one crate, app-rust/, and iris/ is the UI framework alone. See "One app crate" at the end -- it is the layout everything else here assumes.
  • Open across the rest of the docs: docs/IRIS_TODO.md is iris's own list (streaming re-layout is the live one), docs/TODO.md is the Compose app's.

Desktop and phone share the code (Iris, 2026-09-07)

Iris plans to develop a desktop app as well, and asked that most code be sharable between desktop and phone. The tree already has that shape -- iris and app-rust's client and ui modules are platform-free, and src/android/src/desktop are the entry points -- so the rule is about keeping it: a platform module holds only what the platform forces. Today that is JNI, the IME and insets bridge, the surface lifecycle and the bench JNI on Android; winit, argv and the config file on the desktop. What differs is the screen layout, since a phone screen with a finger and a desktop screen with a mouse want different arrangements -- a session list beside the transcript rather than a screen behind it, hover states, keyboard shortcuts. What does not differ is everything a layout is built from: the widgets (a tap button, a text field, a list, a card, a tool-call row), gestures, folding, paging, selection, and the styling -- colours, spacing, type, the surface ladder -- which is the exact same code on both, never a desktop palette beside a phone one. Those are written once in a shared module, with a platform trait underneath when a behaviour genuinely differs (FocusHost, OpenUrl, and the insets/ime_visible feed are the existing examples). Two checks before finishing a change under iris/: does ai-app-desktop still build and run with it, and is any UI logic newly in src/android that a desktop would also need? The bench client (app-rust/src/android/bench_client.rs, ~1000 lines) is the first thing to look at moving, since a desktop bench on the same fixture is layer 2 of the test rig below.

Three test layers, cheapest first (decided 2026-09-07)

Iris's suggestion, adopted and layered: test at the cheapest layer that can answer the question, and go up only when it cannot. The emulator costs minutes a cycle; the desktop window seconds; the headless harness runs inside cargo test.

  1. Headless, in-process, no compositor and no GPU -- the default. iris::harness (iris/src/harness.rs), plus the fixture crate it opens. Harness::new(size, density) builds an Rsc, a UiRenderState and a state whose FocusHost/OpenUrl record what the platform was asked for; frame(t_ms)/frames_until(..) run frames on a clock the test owns, and replay(&TouchScript) feeds a recorded gesture one sample at a time exactly as IrisViewPeer::on_touch_event replays Android's historical samples. The recordings are plain t_ms action x y files under app-rust/touch/, and flick-120hz.touch is the phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total.

    cd app-rust && cargo test
    

    runs in about a second and asserts (a) the flick releases with a real velocity (List::fling_velocity, which only Released(Some(v)) fills), (b) the list travels and settles inside the AOSP spline's own FlingCalculator::duration, (c) a tap moves nothing and opens no link, (d) a long-press-then-drag leaves selected text and does not pan, and (e) the composer clears a simulated 1000px IME inset (Composer::set_bottom_inset). Each was confirmed to fail without its subject rather than assumed: dropping animate(id) from Selection::drag -- the phone's own "fling does nothing" defect -- and starting the fling curve at the wall clock each fail only the flick test; flinging on Tapped fails only the tap test; a 5s LONG_PRESS fails only the selection test; a set_bottom_inset that ignores its argument fails only the composer test.

    What still cannot be answered below layer 3: nothing renders here, so anything about pixels -- glyph rasterisation, the atlas, stale or duplicated primitives, colour, the surface lifecycle, the renderer rebuild -- is invisible to layer 1 and only looked at in layer 2. Frame times are not measurable at either: layer 1 does no GPU work at all and layer 2 runs a debug build on this VM's virtio GPU, so a number from either is not the phone's. Anything JNI (the IME, real insets, the clipboard, battery) is layer 3 by construction: layer 1 records that the platform was asked and layer 2 has no Android platform to ask.

    The one exception, added 2026-09-08: iris/tests/mask_sdf.rs needs a GPU but no compositor and no window -- it asks wgpu for an adapter, runs two functions lifted out of shader.wgsl itself in a compute pass, and compares the answers with the CPU transliteration in iris_core::render::sdf. It sits inside cargo test because what it checks is arithmetic rather than pixels: the fragment stage and the hit test have to agree about where a rounded edge is, and neither layer 1 (which cannot run the shader) nor layer 2 (where a half-pixel disagreement is invisible) can say whether they do. Reach for this shape only when the question is "do these two implementations of one function agree" -- anything about what is drawn is still layer 2.

  2. A phone-shaped desktop window under headless sway -- for looking.

    cd iris && ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png
    

    About 15 seconds warm. --phone sets the private sway output to 1080x2424@120Hz and exports IRIS_SCALE=2.55, which reaches iris the way DisplayMetrics.density does on Android (iris::default::content_scale) -- the desktop backend now lays out in physical pixels with a density instead of dividing into a separate logical space, so both platforms run one path. app-rust's phone example opens the same screen from the same bytes as layer 1 and the Android bench.

    A gesture on screen uses the same recordings:

    ./run-headless.sh phone --phone --dir ../app-rust \
        --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png
    

    writes /tmp/p-before.png and /tmp/p.png either side of the flick; looked at 2026-09-07, the list moved back about seven turns of the fixture and settled.

    swaymsg seat - cursor cannot drive it, and that cost an hour. This compositor runs the headless backend with no input devices (WLR_LIBINPUT_NO_DEVICES=1, LIBSEAT_BACKEND=noop): the cursor commands all report success and nothing whatever reaches the client, with swaymsg -t get_seats showing capabilities: 0 as the only sign. wlroots 0.19 dropped WLR_HEADLESS_INPUTS, and ydotool's uinput device would be ignored by a compositor that is not reading libinput. iris/rig-input's replay-touch uses the virtual-pointer protocol instead, which is a client protocol and needs neither devices nor root, and it parses iris::harness's own TouchScript. Two traps inside it, both found by printing winit's events: a button sent in the same frame as the motion that first puts the pointer over the window is dropped (the client sees the enter, the moves and the release, never the press), so the pointer is positioned and left to settle 200ms first; and a leftover window from an earlier manual run tiles beside the new one, halving the width and producing a screenshot that looks exactly like a duplicated- primitive rendering bug -- swaymsg -t get_tree and pgrep -af examples/phone are the check.

  3. The Android emulator -- platform plumbing and the final pass. JNI, IME, insets, surface lifecycle, the renderer rebuild, and one verification run before a build goes to the phone. Not for iterating on layout.

What has to be reproduced

The app is ~19,000 lines of Kotlin. It splits three ways, and the split is what decides how much of a port is mechanical.

Pure logic with no Compose or Android in it, ~4,500 lines. Api.kt (1,142), Events.kt, EventStream.kt, Sse.kt, TranscriptCache.kt (589, touches java.io.File only), TranscriptSource.kt, MarkdownSyntax.kt, Languages.kt, Highlighter.kt, Ansi.kt, ResetCountdown.kt, Durations.kt, Sizes.kt, ModelName.kt, LoadState.kt, ImportableStream.kt. TranscriptUnits.kt and TranscriptItems.kt (the event fold into rows, ~940 lines) are logic with a handful of Compose annotations. This is also exactly the code that has JVM unit tests today. All of it ports directly, and most of it already has a Rust twin in server/: Events.kt is a hand-kept mirror of session/driver.rs's enum, the highlighter and the syntax scanner exist on the server for the explorer, and the cache compares the server's own JSON lines. Sharing these types between server and app is the single largest "keep things in sync" win available, and it does not depend on which UI framework wins.

Compose UI, ~13,000 lines. Screens, dialogs, the transcript list, the markdown renderer's customisations, tool cards, the file explorer viewer and editor. This is the part a UI framework choice is about.

Android platform code, ~1,500 lines, spread over 20 files. Every one of these is a Java-side object that no Rust framework can replace, because Android only offers them as Java classes:

  • NotificationService — a foreground service holding the /notifications SSE stream while the app is closed, with its ongoing notification, specialUse type and the POST_NOTIFICATIONS request.
  • MainActivity — edge-to-edge, the ACCESS_LOCAL_NETWORK runtime permission (Android 17), singleTop intent routing for aiapp://enroll, notification taps, and the share sheet (ACTION_SEND, any MIME type).
  • ServerConfig — the bearer token sealed under an Android Keystore AES-GCM key, shared with Dev Updater through wg-app-link's :link.
  • EnrollmentScanActivity — the in-app QR scanner (zxing, camera).
  • AttachmentsContentResolver reads of shared URIs, BitmapFactory decode and downscale, EXIF orientation.
  • SessionImage — bitmap decode for produced images.
  • ScrollAnchor, DraftsSharedPreferences; CrashLogfilesDir.
  • TranscriptCachecacheDir.
  • DebugStats/FrameStatsChoreographer frame timing and the render report; runtime-tracing names composables in a system trace.

So "pure Rust" on Android means Rust owns every line of logic and drawing, behind a thin shell of Java stubs, and a packaging step that produces a signed APK. How thin, and whether Gradle is inevitable, are answered below.

How much Java is unavoidable, and why

Rust can call any Android API through JNI (jni crate, with ndk-context handing over the JavaVM and the Activity): posting a notification, startForegroundService, the Keystore, ContentResolver reads, permission requests, WindowInsets, the clipboard. None of that needs a line of Kotlin. What JNI cannot do is define a class that the system instantiates by name from the manifest — an Activity, a Service, an Application, a BroadcastReceiver. Those must exist as dex bytecode inside the APK before any Rust runs, because the framework constructs them and only then calls into native code. NativeActivity is the platform's own stub for the Activity case; there is no NativeService, and android-view ships its own View subclass for the same reason.

So the floor is roughly two Java classes of ten lines each: an Activity and a Service whose lifecycle methods are declared native and registered from JNI_OnLoad, plus whatever android-view already provides. Everything they would have done in Kotlin — insets, intent routing, the SSE follow loop, the notification builder — is Rust reached through those stubs. Writing the stubs in Java rather than Kotlin drops kotlinc from the toolchain; javac comes with the JDK Gradle already needs. Generating the dex from Rust is not worth it: there is no mature Rust dex writer, and the stubs never change.

Can the APK be built without Gradle?

Yes. An APK is a zip containing a binary-XML AndroidManifest.xml, resources.arsc, classes.dex, lib/<abi>/*.so and assets, aligned and signed with the v2 scheme. The tools are aapt2 (manifest and resources), d8 (Java bytecode to dex), zipalign and apksigner, all in the SDK's build-tools, none of them Gradle. Three ways to drive them:

  • A cargo xtask (or build.rs-adjacent script) that runs cargo ndk for each ABI, javac + d8 for the stubs, aapt2 link, zipalign, apksigner. About 150 lines, every step visible, no AGP, no Gradle daemon holding 2.8 GB between builds. The pinned-CA constant becomes a build.rs reading the same certs/ca.pem path.
  • cargo-apk2: the maintained successor to cargo-apk, and unlike it compiles java_sources / kotlin_sources into the dex and declares multiple activities and services with intent filters from [package.metadata.android], with per-profile keystores and optional aapt2. Exactly the shape needed; the question is whether a third-party tool with one maintainer beats 150 lines we own.
  • cargo-apk / xbuild: unmaintained and NativeActivity-only. No.

What Gradle would take with it: Android Lint (which found two real bugs here, but in Kotlin that would no longer exist — with forty lines of Java stubs there is little left for it to find), manifest merging, R8, and the generated-source plumbing. What it gives back: one toolchain, cargo end to end, and Dev Updater keeps calling build-apk.sh exactly as now. Recommendation: the xtask, with cargo-apk2 read for the details it already got right (v2 signing, uses-feature, ABI splits).

The behaviours that are hard to get back

Reading the Compose code for what a replacement must be able to express, rather than what it happens to look like:

  1. The transcript is one selectable body of text. One SelectionContainer around the whole lazy list, so a selection runs from a reply into the tool output beneath it. The framework needs selectable read-only rich text across many rows, with the platform's selection handles and clipboard on the phone.
  2. Rich inline text: markdown with links (one tap detector per text, not a node per link), inline code chips drawn behind the text, tables with wrapping cells and a sideways scroll, syntax-highlighted fences, ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout engine with spans, not just styled labels.
  3. A bottom-anchored virtualised list of variable-height rows, paged in both directions (800-event pages, HISTORY_SCREENS measured in viewports), with a saved scroll anchor per session, "hold the edge nearest the tap" when a row expands (holdTopEdge, done in the layout pass so the wrong frame is never drawn), and rows keyed so that a run of tool calls stays one row while it grows.
  4. The soft keyboard: the composer resizes with the IME, the guard against a stuck inset animation, drafts per session, autocorrect and suggestions from the phone's own keyboard. This is where most Rust frameworks fail on Android today; see below.
  5. Platform integration through the app model: foreground service, notifications, share sheet, deep link, Keystore, camera, back gesture, edge-to-edge insets, local-network permission.
  6. Accessibility names on icon buttons, which the bench scripts depend on (ui-trace taps by label). A framework with no accessibility tree also breaks the measuring rig.
  7. Measurable frames: the debug render report, and a way to attribute a frame's cost to a widget on the real phone.

What the experiments settled

Twelve boxes, all closed between 2026-09-04 and 2026-09-05, and all deleted on 2026-09-08 now that their conclusions live in the code. One line each for what a later session must not re-derive; where a decision needs its reasoning, the reasoning is at the thing itself.

The framework track (E0-E5), against Masonry:

  • E0 -- toolchain. NDK r29 (29.0.14206865) under ~/Android/Sdk, cargo-ndk 4.x. Its API-level flag is -P; -p now means --package.
  • E1 -- android-view's Masonry demo ran here, on the GPU, with an accessibility tree and the phone's real keyboard -- but no autocorrect and no suggestions. The android-view rev this was measured against is pinned in app-rust/Cargo.toml with that history at the pin; accesskit_android's detach-abort is mitigated in iris/src/android/view.rs's raise_if_enabled, and advancing the version is not the fix.
  • E2 -- a transcript in Masonry found the framework-wide gap that blocked the comparison. It lived in ~/src/android-view/e2-transcript and was never committed here.
  • E3/E5 -- the Kotlin shell and the packaging xtask. Both hold: app/shellApp plus the JNI bridge (now app-rust's shell feature) posts a real notification and receives a real share, and cargo xtask apk packages an installable APK with javac/d8/aapt2/zipalign/ apksigner and one disclosed Gradle call, documented at scripts/xtask/src/apk.rs's module doc.
  • E4 -- the same screen on the desktop, which is now app-rust's src/desktop and the ai-app-desktop binary.

The iris track (I0-I5):

  • I0a -- iris is vendored at iris/, history not carried, consumed by path, from iris/iris on gitea at 7b54aaf. It goes back to its own repository once it has proved itself.
  • I0b -- the nightly pin is dated, not rolling (rust-toolchain.toml, one copy in iris/ and one in app-rust/, because a pin applies per directory). Dated because a rolling channel moved impl const Trait to const impl Trait underneath the vendored tree and broke it unattended.
  • I1 -- parley, plus a glyph atlas. Both Iris's call. Parley addresses text by byte offset into one string, which is why the editing model looks the way it does.
  • I2 -- iris runs on android-view: the backend, the Gradle shell, insets, the back gesture and the full InputConnection bridge, with real Gboard suggestions.
  • I3 -- the virtualised list. Since renamed LazySpan, and scrolling has moved out of it into ScrollController -- docs/SCROLL.md is the current design, not this box.
  • I4 -- accessibility names through AccessKit, one flat tree with a synthetic Role::Window root and every named widget a direct child. Flat deliberately: nothing upstream of a named leaf needs a node. This is what lets ui-trace tap by label.
  • I5 -- the transcript screen in iris, with FrameReport for frame timing. Its descendants are app-rust/src/ui and every measurement rig in AGENTS.md.

Two findings from that period that are still load-bearing, kept where they belong rather than here: iris's binding array does not survive real Android hardware (the measurement and the fix are docs/TEXTURES.md's "Implemented, 2026-09-04"), and the emulator has no hardware Vulkan while its GLES is the host's real GPU through virgl (moved to the this-machine-android skill on 2026-09-08, with the gpu-probe output that established it).

Findings that outlive the task that produced them

Kept because the number or the constraint is what stops it being re-derived; the tasks themselves are done and deleted.

The Android release profile, and where the APK's size went (2026-09-07)

Iris asked why the iris bench APK was double the Compose one (20.6 MB vs 10.1 MB). It was almost all libmain.so, built with panic = "abort" and nothing else. Measured cumulatively, arm64 release:

profile.release APK bytes .so bytes delta
panic="abort" only (baseline) 20,678,956 18,546,488 --
+ strip = true 16,435,156 14,302,688 -4,243,800
+ lto = "fat" 15,751,212 13,618,744 -683,944
+ codegen-units = 1 15,185,204 13,052,736 -566,008
+ opt-level = "s" 13,326,076 11,193,608 -1,859,128
+ opt-level = "z" (not adopted) 12,507,276 10,374,808 -818,800
+ platform fonts, no bundled Noto 9,577,940 7,445,472 -3,748,136

opt-level = "z" was not taken: 0.8 MB is not worth the loop vectorisation on a renderer. Everything else is app-rust/Cargo.toml's [profile.android-release] -- a profile of its own rather than release, so the desktop build is not also optimised for size.

Platform fonts, not bundled ones (2026-09-07)

Iris: "remove the font for now; just match what compose does." The Compose app takes body text from FontFamily.Default and code from FontFamily.Monospace and ships no text font, only its Nerd Fonts icon subset. So TextData::register_bundled_fonts, the six include_bytes! Noto constants and iris/core/assets/fonts/'s .ttfs are gone.

The reason this works at all: FontContext::new() was already finding the platform's fonts underneath the bundled ones -- fontique's CollectionOptions::system_fonts defaults to true, with a real backend on both platforms iris ships on (fontconfig on Linux, /system/fonts + /system/etc/fonts.xml on Android). The icon font is the opposite case and is still bundled: a small, closed set of codepoints no system font is guaranteed to have (AGENTS.md's "Icons").

Still unverified, and it is the half that can fail (review R6, 2026-09-07): the bundled fonts originally existed because "bold spans on a real phone rendered as blank gaps of the correct advance width", and the replacement was checked with CJK and emoji on the desktop. The fault was Android's font enumeration resolving a weight/style, so the desktop cannot answer it. Before the next phone build, look at a bold run and at CLOSED_MARK/OPEN_MARK/UP_MARK (U+25B8/BE/B4) on Iris's own device; the emulator's font set is not evidence for hers.

Hit-testing does not consult the mask chain (review R2, 2026-09-07)

Masks are applied in the fragment shader (iris/core/src/render/shader.wgsl); the CPU hit path (UiRenderState::resolved_region) does not look at masks at all. So a straddling row's clipped-away top is invisible and still tappable -- a tap on "Run benchmark" can land on an invisible link in the row behind it. Left deliberately: docs/LAYOUT.md's mask redesign ("masks reference a drawn primitive instead of copying a shape") is where hit-testing gets the shape, and intersecting a chain in resolved_region now would be a second mechanism to unpick.

The port, in order (decided 2026-09-05)

The ordered plan for the rest of the app, decided here per Iris's standing "decide technical questions yourself" instruction -- no serious user-facing tradeoff is in play in the ordering itself.

Where the screens live was settled by the 2026-09-08 reorganisation ("One app crate", below): every screen is a module under app-rust/src/ui, which holds a Screen enum and a back stack -- the direct equivalent of AppRoot.kt's when and MainScreen.kt's tab enum -- with each Compose screen becoming one iris::widget subtree. src/desktop and src/android are thin entry points that call into it, the way AppRoot/MainActivity today call into Compose screens they do not otherwise own. Platform-only code (the notification foreground service, the share target, the QR scanner, the Keystore-sealed token, deep-link enrolment) stays in src/shell + app/shellApp, since none of it is a screen ui could draw.

Order is by risk to the daily-use path, not by screen count: the session screen is what the app is for and where every hard behaviour (paging, cache, keyboard insets, selection) already lives, so it goes first and on the phone as reachable code as soon as possible, before the lower-risk screens.

Every step below assumes the app/ui-sandbox.sh fixtures (AGENTS.md's "The rigs") and the this-machine-android skill's facts (per-checkout AVD, ui-trace by accessibility name, GrapheneOS phone quirks, the adb shell quoting traps) apply unchanged -- read that skill before running any pass condition below that touches an emulator or a real device.

  • P0 -- the phone benchmark gate. Passed. Asked for 2026-09-05, delivered and run on Iris's own phone; the reports are under docs/bench/. Both halves are still in the tree and are how a frame-time comparison is taken: the Compose bench build type (app/, BenchFixture.kt/BenchRun.kt) and the Rust bench feature (app-rust, src/android/bench_client.rs), opening the same checked-in synthetic transcript (app/bench-fixture/assets/transcript.jsonl, never a real one) with no server, driving the same scroll loop and streaming phase, and printing the same report fields. AGENTS.md's "The rigs" is the current description; app-rust/build-apk.sh and run-bench.sh are how it is run.

  • P1 — session screen parity. Started 2026-09-06, on Iris's word: "just continue with the plan for now; try to move towards feature parity for the transcript screen so that the test can be more fair." So P0's "must pass before P1 starts" is lifted — the phone bench continues alongside, and parity is what makes its comparison fair. Sub-order, by what the bench fixture exercises and Compose already draws (tick and date each in place): - [x] P1a — markdown block rendering parity. Done 2026-09-06. Each top-level block is drawn in one of three frames (ui::markdown::BlockFrame) — plain, verbatim, quote — with fences and tables verbatim, headings scaled, and inline styling per span. app-rust/src/ui/markdown.rs is the code and its module doc the design. - [x] P1b — tool-call cards and grouping. Done 2026-09-06. ToolRows.kt/ToolInput.kt ported to app-rust/src/ui/tool.rs: a run of calls is one collapsible group, each card carries its state and summary, and the five ToolState values each have their own appearance. tool.rs's module doc has what was chosen. - [ ] P1c — history paging and jump-to-latest. Wire client::transcript_source into src/ui: the opening page, paging back on scroll with the cushion measured in on-screen viewports (HISTORY_SCREENS, IRIS_TODO "Build (for the port)"), the NothingLoaded/empty/error states drawn distinctly (UI_RULES: design the unknown state first), join_pages at each seam, and a jump-to-latest control that pins to the newest end. Pass condition: the P1 pass condition below, against ui-sandbox.sh with AI_SANDBOX_BIG_MB and --delay. - [ ] P1d — images, the session settings dialog, attachments, usage bar. SessionImage thumbnails (the scaled image widget), the modal primitive and SessionSettingsDialog/ UsageDialog, PendingAttachments over the attachments route (api.rs gap), SessionUsageBar (the gauge widget). - [ ] P1e — keyboard and insets behaviours from AGENTS.md's "Things that have bitten", re-verified on the phone build: composer never left floating after the keyboard closes mid-stream, adjustResize + edge-to-edge together, one recomposition-equivalent per keyboard toggle (the iris insets: log line count).

    History paging backward (with the
    page-boundary healing `app-rust`'s `client` does not have yet, below),
    `TranscriptSource`-backed cache/server stitching, jump-to-latest,
    tool-call cards and grouping, the session settings dialog, composer
    attachments, and the keyboard/insets behaviours AGENTS.md's "Things
    that have bitten" names (the floating-composer bug, `adjustResize`,
    the `imePadding`-vs-raw-inset rule). This is the highest-risk step:
    it is the screen the app is used for, every hour of the day.
    
    **Kotlin it replaces**: `SessionScreen.kt`, `TranscriptList.kt`,
    `SessionSettingsDialog.kt`, `ToolInput.kt`, `ToolRows.kt`,
    `AskQuestion.kt`, `Compaction.kt`, `SessionUsageBar.kt`,
    `PendingAttachments.kt`, `Attachment.kt`, `Attachments.kt`,
    `SessionImage.kt`, `MemoryNote.kt`, `PeerMessage.kt`, `RawBlock.kt`,
    `CodeFence.kt`, `MarkdownLinks.kt`, `MarkdownPieces.kt`,
    `Markdown.kt`, `Bubble.kt`, `ScrollAnchor.kt`, `Drafts.kt`,
    `UsageDialog.kt`, `Chevron.kt`, `Dividers.kt`. (`src/ui`
    already covers the row/markdown/selection/composer core these sit
    on top of or beside.)
    
    **`app-rust`'s `client` needed, and what is not yet covered and must be
    ported first** (`CLIENT_CORE.md`): `TranscriptSource.kt` (deciding
    cache vs. server per page and stitching them — "not started"),
    `TranscriptItems.kt`'s `joinPages`/`healSplitMessage`/`adoptRun`
    (page-boundary healing — "not ported," and paging backward is
    exactly what exercises it), the markdown *block* model beyond
    syntax spans (headings/lists/tables/fences as distinct nodes —
    "not started," needed for `CodeFence`/`MarkdownPieces`' equivalents),
    and the attachments route (`/sessions/{id}/attachments` — "not
    covered" in `api.rs`, needed for `PendingAttachments`/`Attachment`).
    
    **iris widgets missing, → `IRIS_TODO.md`'s new "Build (for the
    port)" section**: row-level accessibility names and the tappable
    link / background-chip primitive (both already listed under I5's
    leftovers — this step is what needs them, not a new ask); a
    history-paging cushion measured in on-screen viewports rather than
    a row count (the `HISTORY_SCREENS` lesson in "Things that have
    bitten," which iris's `List` has no equivalent of yet); a scaled
    thumbnail/image widget for `SessionImage`'s in-transcript images; a
    modal/dialog primitive for the session settings dialog and
    `UsageDialog` (iris has none today — check before building a second
    one for P3/P5); a horizontal gauge/bar widget for
    `SessionUsageBar`.
    
    **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by
    `ui-trace record --do "tap '<label>'"` — a session with the big
    transcript (`AI_SANDBOX_BIG_MB`), a paused/slow-spawning one
    (`AI_SANDBOX_SPAWN_DELAY`), and `--delay` on the server — exercising
    the four states UI_RULES.md says to design first: unknown (a page
    that hasn't loaded), empty (a session with no messages yet), error
    (a failed send/interrupt), and too-long (the big transcript,
    paged). Re-take the I5 `FrameReport` (`iris frame report` in
    logcat, same as I5's box) once this screen has real paging and
    compare it against I5's own numbers, not against Compose's — the
    three measurement sources still are not comparable.
    
  • P2 — the shell merge and a real phone install. Merge this screen's cdylib into the E3/E5 shell (src/shell + app/shellApp) behind the same feature-flag pattern I5 used to extend src/android (decided 2026-09-05), so there is one app — notification service, share target and the real screen — rather than a demo shell and a service shell side by side. Package with cargo xtask apk (E5) and get it onto the real GrapheneOS phone, not just the emulator: arm64-v8a is the ABI that matters there (the emulator here is x86_64), and the this-machine-android skill's facts apply for the first time in this port — no System Tracing on that phone (frame numbers have to come from FrameReport itself), the local-network permission is required there even though AOSP's docs say VPN traffic is excluded, and ui-trace/adb target this checkout's own emulator by default so a real-device command needs -s <serial> explicitly.

    **Kotlin it replaces**: nothing further than E3 already did
    (`Notifications.kt` → `notifications.rs`, `Share.kt` → `share.rs`,
    `ServerConfig.kt`'s Keystore half → JNI calls into `wg-app-link`) —
    this step is wiring P1's screen in as the shell's real content
    instead of E3's placeholder, plus getting a signed APK onto a
    physical device for the first time in this port.
    
    **`app-rust`'s `client` needed**: none new; E3 already covers what the
    shell itself needs. Attachments (P1's gap) matter here too if a
    real photo share is exercised.
    
    **iris widgets missing**: none — this step is integration, not new
    widgets.
    
    **Pass condition**: `cargo xtask apk`, install on the real phone
    over adb, enroll via the deep link, background the app and get a
    real notification, share a text snippet into a session, and
    confirm `ui-trace` can still find controls by name on real
    hardware (accessibility names are not guaranteed to survive a real
    device's TalkBack/AccessKit wiring the way they do in the
    emulator — this is the first time that gets checked for real).
    
  • P3 — root tabs. Screen/MainTab in src/ui: the sessions list, import, models and setups tabs, plus spawn and the app's one level of back-stack navigation (AppRoot.kt's when).

    **Kotlin it replaces**: `AppRoot.kt`, `MainScreen.kt`,
    `SessionListScreen.kt`, `ImportScreen.kt`, `ModelsScreen.kt`,
    `SetupsScreen.kt`, `SpawnScreen.kt`, `BusyItem.kt`,
    `UniqueItems.kt`, `SessionAlerts.kt`.
    
    **`app-rust`'s `client` needed, not yet covered**: setups/machine/provider
    discovery, the models routes (`/models*`, HuggingFace browsing and
    downloads), and importing (`/setups/{id}/importable*`) — all three
    listed "not covered" in `api.rs`'s table and none started; each is
    real work, not a stub, per `CLIENT_CORE.md`'s own caveat.
    
    **iris widgets missing**: a `BusyItem` equivalent — a row dimmed,
    drained of colour, labelled with the operation in progress, that
    does **not** block the list's own scroll/drag the way an overlay
    did on the Compose side (AGENTS.md's "Shared appearance"); a
    `uniqueItems` equivalent is logic, not a widget, and ports directly
    into `src/ui` itself; a confirmation dialog with a toggle switch,
    for the delete-with-`deleteForeign` flow, needs the same modal
    primitive P1 flagged — build it once, here or in P1, whichever
    lands first.
    
    **Pass condition**: `ui-trace` tap-by-name on all four tabs against
    `ui-sandbox.sh`'s fixtures; the two-copies-of-one-session-id
    fixture (AGENTS.md's "Importing") does not crash the list — this is
    the regression `uniqueItems` exists for and it must be exercised
    here, not assumed; the delete dialog's paragraph reads correctly
    both with and without `deleteForeign` toggled (its own text, not
    appended, per AGENTS.md).
    
  • P4 — file explorer. The viewer, the editor with its EDIT_LIMIT, and the 409 conflict.

    **Kotlin it replaces**: `FilesScreen.kt`, `FileViewer.kt`,
    `FileEditor.kt`, `FileLines.kt`.
    
    **`app-rust`'s `client` needed, not yet covered**: `/setups/{id}/dir|file`
    — not in `api.rs`'s covered list, real work, port first.
    
    **iris widgets missing**: nothing beyond what P1 needs (a
    virtualised line-numbered text view is `iris::widget::List` reused,
    per I3's box) — the open question is whether the editor's
    `BasicTextField`-equivalent cost (`docs/EXPLORER.md`'s "what the
    measurements said") reproduces in iris's `TextEdit` at the same
    `EDIT_LIMIT`, which this step has to re-measure rather than assume.
    
    **Pass condition**: `app/ui-sandbox.sh`'s `~/files` fixture tree
    (empty dir, tab/apostrophe names, binary, over `FILE_LIMIT`,
    `chmod 000`, symlinks good and broken, one source file per
    language, `edit-32k.rs`/`edit-128k.rs`/`big-source.rs`) driven by
    name; the 409 reproduced by editing the file on the machine between
    opening it and saving, per AGENTS.md's own recipe.
    
  • P5 — settings, enrollment, notifications permission.

    **Kotlin it replaces**: `SettingsScreen.kt`, `ServerConfig.kt`'s
    remaining non-Keystore parts, `DebugStats.kt`, `FrameStats.kt`,
    `CrashLog.kt`. The QR scanner (`EnrollmentScanActivity`, in
    `wg-app-link`) is platform-only and is **not** replaced — it stays
    a Java/Kotlin activity per decision 1 above, called into from
    `src/ui` the way it is called into from Compose today.
    
    **`app-rust`'s `client` needed**: none new — `config.rs`'s
    `EnrolledServer`/`parse_link` already cover the deep-link half; the
    Keystore half stays the JNI call E3 already wired.
    
    **iris widgets missing**: none identified yet — a plain form screen.
    
    **Pass condition**: enroll via the same `aiappshell://enroll?...`
    link `ui-sandbox.sh`'s banner prints; the local-network-not-allowed
    banner (AGENTS.md's standing-condition text) reads by name when the
    permission is off; `POST_NOTIFICATIONS` request flow checked on the
    real phone from P2, not just the emulator.
    
  • P6 — desktop parity. Root tabs, explorer and settings on src/desktop, matching P3P5 there. Not a Kotlin replacement (the desktop app has no Compose original) — this is closing the gap E4 deliberately left (session list + transcript only).

    **`app-rust`'s `client` needed**: the same P3/P4 gaps, once closed there.
    
    **Pass condition**: `run-headless.sh` screenshots of each tab and
    the explorer against `app/ui-sandbox.sh`, the same way E4's did.
    
  • P7 — the switch of ai-app's main. Point ai-app's production Android build at src/ui/src/shell instead of app/androidApp; decide then whether app/androidApp stays as a reference or is retired — a load-bearing decision (AGENTS.md's "ask before changing load-bearing decisions") to bring to Iris rather than make here.

    **Pass condition**: the full set of pass conditions above, re-run
    once more against a real `ai-server` (not the sandbox) on a real
    phone, side by side with the Compose build until it holds.
    

For the next session

What to do when you pick this up, in order, so nothing here has to be re-derived. The work is done inline, not handed to subagents — Iris said so on 2026-09-08 ("I'm no longer using subagents for this. Please do the work yourself"), so read the code, make the change, run the tests and push, in the session that picked the task up.

  1. Read this file, then AGENTS.md and PLAN.md. The rules there (measure, do not read; fix the rig before accepting its limits; the emulator is this checkout's own) all apply.
  2. Work on the rustify branch of this clone (ai-app-2), not on main and not in ai-app. Nothing on this branch is production until Iris says so. Commit and push as you go.
  3. The E- and I-steps (the framework decision) are done — iris won, decided 2026-09-05. Take the next unchecked P-box in "## The port, in order (decided 2026-09-05)"; P1 — session screen parity — is next.
  4. Every step ends with its measurement written into this file beside the box, and the box ticked or the reason it could not be written in its place. A step that is blocked says by what, not "later". Write it as you go rather than at the end — see "Keep this file current as you work".
  5. Run the existing rigs rather than inventing new ones: ui-sandbox.sh for a server with fixtures, transcript-bench.sh for the scroll baseline, ui-trace for anything positional, emu up for the emulator, iris/run-headless.sh EXAMPLE --shot PNG for an iris example on this displayless machine, and scripts/rigs/gpu-probe to ask a device (this VM, the emulator, or a real phone over adb push) what wgpu features and limits it actually has before building anything on the assumption it does. The Vulkan section below says how to get a Vulkan path in the emulator when a wgpu backend needs one.
  6. Bound anything heavy at the moment you start it. An emulator or a long build gets a deadline — timeout, or a watchdog scoped to the pid you just started — rather than a plan to stop it later. Scope it to that pid: a watchdog written as sleep N; emu down fired into a later experiment here and made a working Vulkan build look like a crash. And stop the emulator when the work needing it is done rather than between tasks.
  7. Decisions belong here with a date and what was rejected, the way PLAN.md does it. Do not put design into commit messages alone.

Things a Rust app changes elsewhere

  • wg-app-link's :link (pinned TLS, enrollment store, QR activity) is Kotlin shared with Dev Updater. The certificate code already exists on the Rust side of the submodule; the pinned-CA build step (generatePinnedCert) becomes a build.rs reading the same path. The QR scanner stays a Kotlin activity, since the camera is a platform feature.
  • Tooling becomes cargo for everything but packaging: cargo test, clippy, fmt cover the whole client, which is the motivation. Gradle remains for the APK, signing (~/.config/ai-app/release.jks) and Dev Updater's build modes; build-apk.sh would call cargo ndk first.
  • The bench scripts (ui-trace by accessibility label) keep working only if the framework exposes names through AccessKit on Android; that is part of E2's pass condition, not a nicety.
  • Icons stay Nerd Font glyphs from the committed subset; Parley/Fontique loads a font file directly, so build-icon-font.sh is unchanged.

One app crate, 2026-09-08 (the repository reorganised)

Iris, reading the tree: "the organization of the rust rewrite is a mess right now… there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." Then, on the crate count: "I'm confused why the app only code needs more than one crate though."

What it was

Nine cargo workspaces, each with its own Cargo.lock and target/, and the port's project code in five places — iris/transcript-ui, iris/transcript-fixture, iris/desktop-app, iris/android-app (all inside the framework), plus client-core and android-shell at the root. Two root markdown files sat outside docs/.

What it is

One crate, ai-app, in app-rust/. Modules, not crates:

was is
client-core src/client
iris/transcript-ui src/ui
iris/transcript-fixture src/ui/fixture.rs + tests/, touch/
iris/desktop-app src/desktop + src/bin_desktop.rs
iris/android-app src/android + android-project/
android-shell src/shell

iris/ now holds core, macro, the iris crate, tabs-ui and rig-input — framework only, with no mention of a session, a transcript, a setup or a server anywhere in it.

Why one crate really is enough

Each split had a stated reason at the time; on inspection only two survived, and one of those is not in app-rust at all.

  • client-core separate from the UI was "pure logic with no framework dependency". That property is worth keeping and does not need a crate: iris is behind the screens feature and src/client/ may not reach it. An invariant on a module instead of on a manifest, stated in docs/CLIENT_CORE.md.
  • transcript-fixture separate from transcript-ui was so the headless harness and a desktop window opened the same bytes. Both are now the same crate, so it is src/ui/fixture.rs behind a fixture feature (1.9 MB of include_str! must not reach a phone build) with the six harness suites in tests/.
  • Two Android .so names, libmain.so for the iris app and libandroid_shell.so for the Kotlin shell's JNI bridge, looked like the one hard constraint: a package produces exactly one library artifact. It dissolves because P2 already plans to merge those two Android apps into one. So both faces come out of one package as libai_app.so, picked apart by features (--no-default-features --features shell keeps wgpu, parley and iris out of the Compose app's APK), which is the direction of travel rather than a workaround. xtask apk and app/shellApp's System.loadLibrary were updated to match.
  • A desktop binary and an Android cdylib in one package is not a problem: iris itself already target-gates winit against android-view in one manifest, and the same table does it here. build-apk.sh passes --lib so cargo ndk never tries to build the desktop binary.
  • event-model stays a crate, and is the one split that was never optional: server/ depends on it too, so a crate is what makes the backend and the app agree by construction. Iris chose to leave it at the repo root rather than inside app-rust/, since it is the contract between the two rather than app code.

So: three workspaces where there were nine — event-model, server, app-rust — plus iris and xtask.

Things that moved with it, worth knowing

  • The toolchain pin is per directory. app-rust/rust-toolchain.toml is a copy of iris/'s, because client-core used to build on stable and now shares iris's dated nightly. Two consequences appeared immediately: two needless_range_loop warnings in the markdown highlighter (fixed), and four AtomicBool::fetch_update deprecations from inside jni 0.22's native_method! macro. The last are not ours to migrate — the fix is a jni release — so src/lib.rs carries an #[allow(deprecated)] scoped to mod shell with that reason written at it.
  • The Android release profile is android-release, not release. The aggressive settings iris/android-app had (panic = "abort", opt-level = "s", fat LTO) would otherwise apply to the desktop build too, which is a testing surface. build-apk.sh passes --profile android-release / --profile android-dev.
  • iris/run-headless.sh grew --dir DIR, defaulting to iris/. The rig belongs to the framework; the examples it usually runs no longer do. replay-touch is still built from iris/.
  • The log target changed from client_core to ai_app (src/client/log_ring.rs's is_own_target).
  • Not renamed, deliberately: the Android application id and Java package are still dev.iris.android.demo and the label is still "iris android-view demo", both now misleading. Changing them changes the app's identity on Iris's phone (a side-by-side install rather than an upgrade) and the DevLogProvider authority Dev Updater reads, so it is hers to decide rather than a tidy-up to make quietly.

Verified

./scripts/run-tests.sh (event-model, server, app-rust) and cd iris && cargo test green; cargo clippy --all-targets and cargo fmt clean in every workspace. cargo ndk -t x86_64 links libai_app.so; ./build-apk.sh debug --abi x86_64 produces an installable APK; installed and launched on this checkout's emulator, drawing through Gl … virgl as expected. The phone-sized headless screenshot (run-headless.sh phone --phone --dir ../app-rust --shot …) renders the transcript unchanged.