Files
ai-app/docs/RUST.md
T
irisandClaude Opus 5 9bf714fa2e The frame report says what it measured: idle is not stutter, waiting is not late
Iris's phone came back "now THAT is smooth", and reading that run against
the bench's own timings found three things the report was getting wrong
-- two of them shipped yesterday in the fix for the last three.

`missed vsyncs` counted idleness. Every gap between frames was treated as
cadence, so the bench's own pauses read as stutter: 276 for sixteen 300ms
rests between flings, 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 one.

`late` counted the swapchain 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 -- 0.4ms of work and
5.7ms of waiting is not a late frame. It is judged on `FrameParts::work`.

And 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. The first attempt at measuring it instead took the
fastest tenth of the gaps and reported 88Hz for this repo's 60Hz
emulator, whose app manages 54 -- 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. A sustained rate is
a floor and cannot do that. Both are printed when they disagree.

Also corrected in the docs: "103fps on a 120Hz screen" divided the fling
phase by its whole duration, rests included. Both runs sustained ~120.3fps
through the motion, so the callback ordering was never costing frames --
what changed is the clock, which moves no frame count at all, which is
exactly why nothing in a report could show it.

`fling_profile.rs` is `frame_profile.rs` and gained a stream run, which
says where the frame time now is: folding an arriving event is 0.35ms and
applying the diff 0.41ms, while the frame is 3.86ms here and 9.5ms on the
phone. 401 events move the item count 652 -> 654, so nearly every one is
a delta into the same row -- the cost is re-shaping one growing message,
not `fold_event`'s per-event clone, which was the hypothesis and is what
measuring it ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:08:30 -04:00

1026 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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*: `app-rust/tests/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. **Not yet designed**:
making a row's text append incrementally rather than reshape touches how
`TranscriptRow` holds its shaped text, which is load-bearing enough to
raise before building.
### 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 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.