906 lines
50 KiB
Markdown
906 lines
50 KiB
Markdown
# Moving the app to Rust
|
||
|
||
Plan for a native Rust phone app with full feature parity and a shared desktop
|
||
UI. It uses [iris](https://github.com/cat16/iris); platform-specific entry
|
||
points are acceptable, but shared screens, widgets, and styling are not
|
||
duplicated. The result must stay lightweight and preserve native behavior and
|
||
performance.
|
||
|
||
## Keep this file current as you work
|
||
|
||
Keep open work, current design, measured constraints, and dead ends that would
|
||
otherwise be repeated. Delete completed plans and migration narratives. Name
|
||
the command and measured value when evidence matters.
|
||
|
||
## Current status
|
||
|
||
- **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 port is one crate under `app-rust/`; `iris/` is only the UI framework.
|
||
- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own
|
||
list; `docs/TODO.md` is the Compose app's.
|
||
|
||
## Desktop and phone share the code
|
||
|
||
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
|
||
`SelectionController::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.** A
|
||
`SelectionController` is registered directly on the lazy list, with no
|
||
selection widget in the layout tree, so a selection runs from a reply into
|
||
the tool output beneath it. Each parent supplies either draw order or one
|
||
visual axis for its immediate children; ordering is resolved only when
|
||
selection queries it. Iris owns the selection handles because its text is
|
||
drawn into one surface, while the platform supplies the clipboard and
|
||
related system services.
|
||
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.
|
||
|
||
## Measurements and constraints
|
||
|
||
### The Android release profile and APK size
|
||
|
||
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 cannot say
|
||
|
||
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 frame time
|
||
|
||
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 shaping
|
||
|
||
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 `Text` 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.
|
||
|
||
### Arena delta uploads
|
||
|
||
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. Layout can also write a provisional instance and restore it within
|
||
one frame; `Primitives` remembers the pre-frame bytes and cancels that dirty
|
||
bit when the GPU-visible result is unchanged. `arena_churn` prints both
|
||
numbers so either 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, streamed-frame CPU p50 is
|
||
0.12ms, from 1.18ms before this layout change. Arena size and upload floors
|
||
are unchanged.
|
||
|
||
Pinned growth now uses the same subtree translation as scrolling. A
|
||
container can retain a child-coordinate move slot through
|
||
`Painter::set_child_offset`; `LazySpan` keeps retained rows in stable local
|
||
boxes and changes that one slot when its anchor moves. It still walks the
|
||
visible run to virtualise it, but unchanged rows no longer acquire new
|
||
absolute primitive regions. Over the fixture's 401 streamed events, instance
|
||
upload is **1.1% against a 1.1% floor**, from 71.9% against 71.8%; median
|
||
instance bytes per frame are **1,488**, from 176,496. This is framework
|
||
layout/rendering behaviour and the transcript screen contains no special
|
||
case for it.
|
||
|
||
### The Android release profile uses `opt-level = 3`
|
||
|
||
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
|
||
|
||
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.
|
||
|
||
## The port, in order
|
||
|
||
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.
|
||
|
||
**Bench-only cleanup still open**: the diagnostics report pane draws over
|
||
transcript rows. `REPORT_MAX_HEIGHT_DP` constrains its claimed height, but the
|
||
pane is neither masked nor scrollable despite its construction comment saying
|
||
it is both. This is an `app-rust` defect, not an iris framework item.
|
||
|
||
- [ ] **P1 — session screen parity.** Continue in this order:
|
||
- [ ] **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).
|
||
|
||
The remaining screen work is history paging and jump-to-latest, the
|
||
session settings and usage dialogs, images and 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** (`CLIENT_CORE.md`): the paging half
|
||
is ready — `transcript_source`, `join_pages` with seam healing, and
|
||
`markdown_blocks` are all ported. The remaining client gap in P1 is
|
||
the attachments route (`/sessions/{id}/attachments`), needed by P1d's
|
||
`PendingAttachments`/`Attachment`.
|
||
|
||
**iris widgets missing, → `IRIS_TODO.md`'s "Build (for the port)"
|
||
section**: the distance-to-unloaded-edge query P1c needs for its
|
||
viewport-sized history cushion; per-range text backgrounds for inline
|
||
code; a fitted image widget; one modal/dialog primitive reused by the
|
||
settings and usage dialogs; and a horizontal gauge for
|
||
`SessionUsageBar`. Tappable links already exist. Row accessibility
|
||
names are app content applied through iris's existing `.label()` API,
|
||
not a missing framework widget.
|
||
|
||
**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.
|
||
|
||
**UI still needed**: `BusyItem` is app-specific appearance — a row
|
||
dimmed, drained of colour and labelled with the operation in progress
|
||
without blocking the list's scroll — so it belongs in `src/ui`, not
|
||
iris. `uniqueItems` is app logic there too. Iris itself still needs
|
||
the modal primitive P1 flagged and a toggle switch for the
|
||
delete-with-`deleteForeign` flow.
|
||
|
||
**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.
|
||
|
||
## One app crate
|
||
|
||
The Rust client is one `ai-app` crate in `app-rust/`: platform-free code
|
||
is under `src/client` and `src/ui`, while `src/desktop`, `src/android`, and
|
||
`src/shell` contain the platform entry points. The fixture is behind its
|
||
own feature so its 1.9 MB `include_str!` does not enter ordinary phone
|
||
builds.
|
||
|
||
`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.
|
||
|
||
`src/client` must not depend on iris. Features select the crate's face:
|
||
`screens` for UI builds, `shell` for the Compose shell bridge, and `bench`
|
||
for the fixture. The Android faces both produce `libai_app.so`.
|
||
|
||
`event-model` remains separate because both the server and client depend
|
||
on that wire contract. Iris remains a separate UI-framework workspace and
|
||
must contain no product concepts.
|
||
|
||
### Build constraints
|
||
|
||
- **The rolling nightly setting is per directory.** The toolchain files in
|
||
`app-rust`, `iris`, and `scripts/rigs/ui-profile` must stay synchronized.
|
||
- **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 --dir DIR`** selects the workspace containing the
|
||
example; it defaults to `iris/`.
|
||
- **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 changing them
|
||
requires an explicit migration decision.
|