1185 lines
66 KiB
Markdown
1185 lines
66 KiB
Markdown
# 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](https://github.com/cat16/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).
|
||
- `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory`
|
||
decode and downscale, **EXIF** orientation.
|
||
- `SessionImage` — bitmap decode for produced images.
|
||
- `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`.
|
||
- `TranscriptCache` — `cacheDir`.
|
||
- `DebugStats`/`FrameStats` — `Choreographer` 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](https://github.com/mzdk100/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.
|
||
|
||
### The fling stutter, and what a frame report could not say (2026-09-09)
|
||
|
||
Iris, from her phone: *"I'm noticing some stuttering when flinging in
|
||
particular. Harder to notice with my finger directly moving the scroll."*
|
||
Her report had the fling phase at 3396 frames over 33.0s -- 103fps on a
|
||
120Hz screen -- with p50 6.3ms and 13.4% "late".
|
||
|
||
**The report was not measuring what its own labels claimed.** Three
|
||
things came out of chasing it, and the first two are corrections to the
|
||
instrument rather than to the renderer:
|
||
|
||
1. **The swapchain acquire was counted as iris's CPU work.**
|
||
`AndroidRenderer::draw` timed `queue.submit` + `present()` and called
|
||
everything before it `redraw_to_submit`, but `get_current_texture` --
|
||
which *blocks* until the compositor frees an image -- sits in that
|
||
span. An app comfortably ahead of the display spends most of every
|
||
frame there, so a healthy fling read as several milliseconds of iris
|
||
being slow. A frame is now three measured parts (`FrameParts`:
|
||
`build`, `acquire`, `submit`), per phase as well as per run, because
|
||
they do not divide the same way in every phase.
|
||
|
||
2. **Nothing could say a frame was never produced.** `late` counts frames
|
||
that cost more than a budget, which is not the thing a reader sees:
|
||
a frame that is late but drawn shows up on the next vsync, while a
|
||
frame that never happens leaves the previous one on screen for two
|
||
refreshes. `PhaseStats::missed` counts vsyncs nothing was drawn for,
|
||
from the gap between consecutive frame times.
|
||
|
||
3. **The frame loop asked for its next frame after doing the work.**
|
||
`Choreographer.postFrameCallback` schedules for the next vsync *after
|
||
the call*, so any frame whose work ran past the vsync boundary
|
||
registered too late for the next one and got the one after -- one
|
||
frame over budget silently cost a second frame as well. It is asked
|
||
for immediately after `tick_animations`, before the layout and the
|
||
draw.
|
||
|
||
And one that is about the animation rather than the report: **the fling
|
||
was advanced on `Instant::now()`, not on the vsync the callback carried.**
|
||
`do_frame`'s `frame_time_nanos` was discarded. Frames are *presented* on
|
||
an even cadence whatever clock they are computed on, so sampling the
|
||
spline at "whenever the callback got to run" moves the content by an
|
||
uneven distance every frame -- a shimmer with no frame late enough to
|
||
appear in any report, and it is exactly the asymmetry Iris described,
|
||
since a drag's positions come from the finger's own timestamped samples
|
||
and never had it. `sense::PointerClock` is now `sense::DeviceClock` and
|
||
the view keeps **one**, anchored by whichever of a touch or a frame
|
||
arrives first, so a fling is advanced on the clock its velocity was
|
||
measured on.
|
||
|
||
What the CPU side is *not*: `scripts/rigs/ui-profile`'s `frame_profile.rs` (AGENTS.md's
|
||
rig list) puts iris's own per-frame work during a warm fling at p99
|
||
0.26ms, with only one frame in six laying anything out at all. The
|
||
multi-millisecond spikes are first-pass only.
|
||
|
||
**The result, from Iris's phone the same day: "now THAT is smooth. I
|
||
couldn't actually see any lag myself."** With three corrections to what
|
||
the report meant, found by reading that run against the bench's own
|
||
timings:
|
||
|
||
- **The frame rate never was the problem, and the first reading of it was
|
||
wrong.** "103fps on a 120Hz screen" divided the fling phase's frames by
|
||
its whole duration, which includes sixteen deliberate 300ms rests. Both
|
||
runs sustained ~120.3fps through the motion itself. So the callback
|
||
ordering was not costing frames -- what changed is the *clock*, which
|
||
moves no frame count and is the whole point: an uneven sample of an
|
||
even cadence cannot show up in any frame-time percentile.
|
||
- **`missed vsyncs` counted idleness.** Every gap was treated as cadence,
|
||
so the bench's own pauses read as stutter: 276 for sixteen 300ms rests,
|
||
2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms
|
||
stream gaps -- each within a few percent of the arithmetic. A gap now
|
||
measures anything only if the frame before it had asked for another.
|
||
- **`late` counted the vsync wait as cost.** A well-paced loop spends
|
||
each frame blocked in the acquire, so its total sits at exactly one
|
||
refresh period and every frame lands on the budget boundary. It is
|
||
judged on `FrameParts::work` -- the total minus the acquire -- now.
|
||
- **The refresh rate is the larger of what the platform claims and what
|
||
the run sustained**, because each can only be wrong one way.
|
||
`Display.getRefreshRate()` answered 60 for a run that drew 3405 frames
|
||
in 33.1s, since a phone that varies its rate answers with whatever mode
|
||
it is in when asked. And `FrameReport::sustained_frame_hz` is a *floor*:
|
||
an app that cannot keep up says nothing about the panel. The first
|
||
version of it took the fastest tenth of the gaps rather than the
|
||
sustained rate and reported **88Hz for this repo's 60Hz emulator**,
|
||
whose app manages 51 -- a budget no frame there could meet, invented
|
||
out of the app's best moments, and caught only by running the corrected
|
||
report on the emulator before shipping it. The two are printed together
|
||
whenever they disagree.
|
||
|
||
The signature of the fixed loop, from that run: `build p50 0.4ms,
|
||
acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of
|
||
work and the rest of the refresh period spent waiting its turn.
|
||
|
||
### Streaming is where the frame time is now (2026-09-09)
|
||
|
||
Measured after the fling was fixed, and it is not where it looks.
|
||
`frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and
|
||
applying the diff to the widget tree is 0.41ms, while the frame that
|
||
follows is 3.86ms on this desktop and 9.5ms of `build` on Iris's phone --
|
||
over a 120Hz budget on its own. 401 streamed events move the item count
|
||
from 652 to 654, so nearly every one is a *delta into the same row*: the
|
||
cost is re-laying out and re-shaping one growing markdown message on
|
||
every delta.
|
||
|
||
`fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the
|
||
exact shape of the Compose lesson in AGENTS.md's "Things that have
|
||
bitten" -- and measuring it is what ruled it out.
|
||
|
||
### Incremental text: parley cannot, and it turns out not to matter (2026-09-09)
|
||
|
||
Iris asked to investigate incremental text rendering and hoped parley
|
||
supported it. **It does not, by design.** The crate's own docs: a
|
||
`Layout` "supports re-linebreaking and re-aligning many times... but if
|
||
the text content or the styles applied to that content change then a new
|
||
`Layout` must be created". Its `LruCache` caches harfrust's per-font
|
||
shaper data, instance and plan -- not shaped runs -- and its own
|
||
`PlainEditor::update_layout` rebuilds the whole layout from the whole
|
||
buffer on **every keystroke**. So there is nothing to adopt, and adding
|
||
it would be upstream work in parley.
|
||
|
||
**And the app already does the thing incremental layout would buy.**
|
||
`RowBlocks::apply_delta` keeps one `TextEdit` per top-level markdown
|
||
block and re-shapes only the block a delta landed in; re-splitting the
|
||
markdown to find that block is 18µs at 18,000 characters and comparing
|
||
the blocks is 470ns. Neither is the cost.
|
||
|
||
**The 9.5ms is a bench-fixture artifact.** Measured with
|
||
`frame_profile.rs`:
|
||
|
||
- Re-shaping a block is linear in its length -- ~0.23ms per 1,000
|
||
characters on this desktop, so a message grown to 17,600 characters
|
||
costs 4.1ms on its *last* delta and 842ms of shaping over the whole
|
||
reply.
|
||
- The fixture's streamed message is **14,888 characters in one block** --
|
||
a synthetic run-on paragraph with no blank line in it, so every delta
|
||
reshapes all of it. That is the whole of the frame: 3.5ms of the
|
||
measured 3.86ms.
|
||
- Real replies are not like that. Over **7,706 top-level blocks from
|
||
3,675 real assistant messages** on this machine (block lengths only;
|
||
no content left the machine): p50 147 characters, p90 449, p99 836,
|
||
largest 1,580, and **nothing above 4,000**. Code fences are smaller
|
||
still -- 170 of them, p50 126, largest 589.
|
||
- At those sizes a live reshape is 48µs (p50), 208µs (p99) and 372µs
|
||
(the largest block ever seen), or roughly 0.12-0.93ms on the phone.
|
||
Comfortably inside a 120Hz budget, with no incremental anything.
|
||
|
||
**So: incremental text is not worth building** -- and Iris agreed, with
|
||
the fixture changed instead (2026-09-09: *"let's switch to new lines for
|
||
the test, and also let's keep the single line around for stress + could
|
||
be something to try to optimize later"*). What landed:
|
||
|
||
- The streamed reply gets a blank line every 4-12 deltas, so it is 53
|
||
blocks with a longest of 502 characters instead of one of 14,888. The
|
||
streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to
|
||
**p50 2.20ms / p90 5.90ms / worst 8.78ms** here.
|
||
- The run-on message is kept as the first two backlog events, sized just
|
||
under `text_cap`'s 16 KiB so it draws in full. The *streaming*
|
||
pathology is kept in `frame_profile.rs` instead of the fixture, because
|
||
it needs a growing block and iterating on it there costs a second
|
||
rather than a two-minute phone run.
|
||
- Adding it is **purely additive**: the random state is saved and
|
||
restored around those two events, so every other backlog event is
|
||
byte-identical. That is not cosmetic -- `phone_screen.rs`'s
|
||
`a_long_press_and_drag_selects_text` replays a real recording at
|
||
(300, 1000) and failed the first time round, when the insertion shifted
|
||
what was under it.
|
||
- `BACKLOG_COUNT` is 3202 now, in `generate.py`, `fixture.rs` and
|
||
`BenchFixture.kt`. The split is by line index, so a stale copy opens a
|
||
different half of the file.
|
||
|
||
**The cap does not save a streamed reply, and this is worth knowing
|
||
before optimising anything here.** Iris asked whether the newest message
|
||
caps: it does not, deliberately -- `row::build_row`'s `cap` is `false`
|
||
for the live tail because a row that grew while capped would appear to
|
||
stop growing, and a reply that grows *past* the cap never gets caught
|
||
either, since it grows through `apply_delta`. So a streamed block's
|
||
shaping cost has no ceiling: at the measured ~0.23ms per 1,000
|
||
characters (about 2.5x that on the phone), a 50,000-character block
|
||
would be ~29ms per delta and a 100,000-character one ~58ms. Real replies
|
||
do not do this, which is why it is not urgent; nothing *stops* one doing
|
||
it, which is why the stress case is kept.
|
||
|
||
**What the remaining streaming cost is, and is not.** With realistic
|
||
blocks the reshape is no longer the cost: layer 1's frame went to p50
|
||
2.20ms, spread over frames that added a block (p50 3.56ms, 56 of 401)
|
||
and frames that did not (p50 1.94ms). Folding is 0.12ms and applying the
|
||
diff 0.35ms.
|
||
|
||
**But the emulator's `stream: build p50` did not move -- 10.4ms before
|
||
the fixture change, 10.5ms after** -- while layer 1's CPU frame nearly
|
||
halved. So most of a streaming frame on a real GPU path is something
|
||
layer 1 builds and never uploads, and therefore cannot time. The
|
||
candidate, and the arithmetic behind it:
|
||
|
||
- The screen holds **11,568 primitives** by the end of the stream phase.
|
||
- `UiRenderNode::update` re-uploads the *entire* instance and primitive
|
||
arenas whenever `primitives.updated` is set, which a text change sets
|
||
every delta -- about 370 KB per delta at 32 bytes an instance, before
|
||
the primitive data itself. `ArrBuf::update` also **recreates the
|
||
buffer** whenever its length changes, which adding glyphs does on
|
||
nearly every delta, and a recreated buffer means a fresh bind group
|
||
too.
|
||
- The fling phase is the control that makes this convincing: it moves
|
||
the same 11,568 primitives every frame through `move_offsets` -- a
|
||
small buffer, no arena rewrite -- and its `build p50` is **0.4ms**
|
||
against streaming's 10.5ms, on the same screen and the same content.
|
||
|
||
So the next thing to look at for streaming is **uploading only what
|
||
changed** rather than the whole arena, not anything about text. Splitting
|
||
the reply into blocks was still right -- it is what makes the fixture
|
||
representative, and it halved the CPU half -- but it was never going to
|
||
move this, and it slightly increases the primitive count.
|
||
|
||
### The arenas upload deltas, and stopped being 11x too big (2026-09-09)
|
||
|
||
Done, and measured by `scripts/rigs/ui-profile`'s `arena_churn` -- see
|
||
AGENTS.md's entry for the rig and the numbers. The arithmetic above was
|
||
right about the symptom and wrong about the cause being the upload
|
||
strategy alone. Three things, in the order they had to be fixed:
|
||
|
||
1. **`ArrBuf` reallocated on every length change**, and a fresh buffer's
|
||
contents are undefined, so a partial upload could not have been
|
||
correct in the first place. It has a capacity now: geometric growth,
|
||
never shrinking, and `update` says whether the `Buffer` identity moved
|
||
so a caller can rebuild its bind group and force the whole range
|
||
dirty. This alone took the glyph array from 95% re-uploaded to 3%.
|
||
2. **A redraw freed its primitives and pushed new ones.** Freed slots are
|
||
not reusable until the end of the frame (a layer's draw order still
|
||
names them), and nested provisional layout meant the arena's high-water was
|
||
the *transient* push count: 17 million pushes across 401 deltas, and
|
||
127,443 slots for 11,569 live primitives, growing linearly with the
|
||
transcript. A redraw now gets its old handles back as a recycle pool
|
||
(`Painter::take_recycled`, `Primitives::recycle`) and writes into the
|
||
slots it already holds. The arena is exactly the live count now, and
|
||
the CPU frame fell from p50 2.20ms to 1.39ms as a side effect, since
|
||
the freeing and draw-order renumbering went away.
|
||
3. **Nothing tracked which entries changed.** `util::Dirty` is a bitset
|
||
per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is
|
||
O(1), and the read-back is one word per 64 entries. A `min..max` span
|
||
was rejected on measurement (a frame's changes land in 5-20 scattered
|
||
runs, so a span is nearly the whole buffer) and so was a `Vec` of
|
||
indices (thousands of marks per frame would mean an allocation and a
|
||
sort).
|
||
|
||
The trap that only the rig could have caught: writing an entry is not the
|
||
same as changing it. Recycling rewrote every glyph of every moved row with
|
||
identical bytes, marking 73% of the glyph array against 0.6% genuinely
|
||
changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before
|
||
marking, and `arena_churn` prints both numbers so the gap cannot reopen
|
||
unnoticed.
|
||
|
||
**Layout has no measurement mode.** A widget is drawn provisionally only
|
||
when its size cannot be known yet, and that retained drawing is moved into
|
||
place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized`
|
||
report an exact `Len`; a debug assertion compares every hint with the real
|
||
draw result. If final allocation changes a child's size, `Painter::place`
|
||
redraws it in that box. Otherwise placement is one move-offset write.
|
||
|
||
Measured over the fixture's 401 streamed events: the busiest frame makes
|
||
176 `Widget::draw` calls and the worst widget is called four times.
|
||
Streamed-frame CPU p50 is 0.35ms, from 1.18ms before this layout change.
|
||
Arena size and upload floors are unchanged.
|
||
|
||
**What is left, and it is a layout question rather than an upload one.**
|
||
Stream instances upload 71.9%, against a 71.8% floor: the list is pinned to
|
||
the newest end, so a growing reply moves every row, and a row's instances
|
||
carry an absolute region. Moving a subtree is supposed to be one
|
||
`move_offsets` write (LAYOUT.md section 2); something on this path is
|
||
redrawing instead.
|
||
|
||
### The Android release profile is `opt-level = 3`, not `"s"` (2026-09-09)
|
||
|
||
The table above was measured in bytes only. `"s"` costs the loop
|
||
vectorisation and inlining a renderer runs on: over the same warm fling
|
||
eight times, iris's own per-frame work is p90 0.15ms / p99 0.42ms at
|
||
`"s"` against p90 0.09ms / p99 0.26ms at `3`. The arm64 release APK goes
|
||
from 9,745,704 to 11,542,646 bytes (+1.8 MB) -- the same trade the table
|
||
refused for `"z"`, one level further up. Iris raised it herself
|
||
(*"I'd make sure it's in release mode"*); the build always was, and this
|
||
was the part of "release" that was not about speed.
|
||
|
||
### 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 `.ttf`s 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.
|
||
|
||
- [x] **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 P3–P5 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.
|