Files
ai-app/docs/RUST.md
T
irisandClaude Fable 5.1 ba0f2ea93f docs: the 22:16 report reconciled with what was actually run
RUST.md's "Shell lost" section and IRIS_TODO.md's matching paragraph both
said item 4's fix was written but never built or tested. It was committed
in ba2afba with its test passing, so both were stale the moment that
landed and read as if nothing had been run at all.

Replaced with one section per item, saying what was fixed, what was
measured on this checkout's emulator and what the phone still has to
settle: items 2 and 3 ticked with their numbers, item 4 ticked on the code
with phone confirmation still owed (no Vulkan adapter here), item 1 left
open with the exact logcat line for Iris to look at. The two pre-existing
faults found on the way -- the 16-deep move chain and the API-29 JNI calls
-- are recorded where the next reader will hit them.

IRIS.md gains the public-surface entry: `Widget::tick`,
`UiData::animate`/`tick_animations`, `FlingCalculator`'s density and
coefficient, and `MOVE_CHAIN_LIMIT`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:12:03 -04:00

6333 lines
393 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 question Iris asked on 2026-09-04: what are the
options for switching the phone app to Rust, ideally pure Rust with one UI
framework shared with a future winit-based desktop application, at full
feature parity and without giving up anything native, performance
especially. Constraints she set: no Dioxus and nothing that draws through a
WebView; **no UI DSL** (which rules out Makepad and Slint); the result
should stay 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;
this clone is where things get tried before anything is committed to
`ai-app`. Her own library, [iris](https://github.com/cat16/iris), is the
**in-house framework to be built up** for this, with Masonry as the
yardstick it is measured against.
Decisions get a date and a reason here, the way `PLAN.md` does. A section
describes something that has been tried only where it says so, with a date.
## 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.
## Where things stand (2026-09-06, orchestrator plan)
Written by the design agent on picking the branch up after a `/clear`, so
the next session can resume from here. P0 is delivered and Iris's phone
report v2 is in (`docs/bench/iris-phone-v2-2026-09-06.md`); **P1 stays
gated on her verdict**, so this pass works the P0 defects and the pure
prerequisites in this order. Each item is ticked here by the agent that
closes it.
### 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 workspace already has that
shape -- `iris`, `client-core`, `transcript-ui` and `tabs-ui` are
platform-free, and `android-app`/`desktop-app` are the entry points --
so the rule is about keeping it: **a platform crate 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
crate, 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 `desktop-app` still build and run with it, and is
any UI logic newly in `android-app` that a desktop would also need?
The bench client (`android-app/src/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, rig not yet built)
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/src/layout_tests.rs` already builds a tree over `UiRenderState`
with no window, `sense.rs`'s gesture tests feed fabricated
`CursorState`s with their own times, and `List::tick_fling` is
driven by hand. Extend that into one harness that opens
`transcript-ui`'s screen on `app/bench-fixture` (as `bench_client.rs`
does on Android, no server), at the phone's logical size and
`content_scale` from `docs/bench/iris-phone-v2-2026-09-06.md` (2.55,
120Hz -- read, never typed from memory), ticks frames, and feeds a
**replayed touch stream** from a trivial file of `(t_ms, action, x,
y)` lines with `cursor.time` taken from the file. That is what the
emulator cannot do at all: the batched 120Hz flick from the phone
report becomes a deterministic test asserting on scroll offset and on
the `iris drag release:` velocity. Anything about layout, scroll
position, selection, focus or fold state is answered here, with
assertions rather than eyes. Nothing renders; a widget's placed
rectangle is the evidence.
2. **A phone-shaped desktop window under headless sway -- for looking.**
`iris/run-headless.sh` already runs a winit binary under a private
sway and screenshots it with `grim`. Add a `--phone` mode (output and
window at the phone's size and scale, with the scale reaching iris
the way Android's does so dp layout runs at that density) and
touch-shaped mouse input: a left-button drag pans and flings through
`DragArbiter`, long-press selects, no hover. One input path, not a
parallel one (`CODE_RULES`). Colour, spacing, text and anything a
person has to see is answered here. `swaymsg seat - cursor
move/press/release` drives it when a gesture is needed on screen.
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.
Pass condition for the rig: `cargo test` runs a fixture-backed headless
transcript screen with a replayed flick and asserts a nonzero release
velocity and a moved scroll offset; one command opens the same screen in
a phone-shaped window and screenshots it. Record the commands here when
it lands.
### The 22:16 phone report, worked 2026-09-06/07
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
2026-09-06, 22:16"; this is what was found and what was run. Item 4 was
committed on its own (`ba2afba`); items 1-3 and everything below landed
together after the emulator evidence.
**Item 4, text cooked after a resume -- root cause, fixed in `ba2afba`.**
Not "the cached text primitives are never redrawn": they *are*.
`IrisViewPeer::surface_changed` (`iris/src/android/view.rs`) calls
`render.resize(...)` on every surface event including the new-renderer
branch, which sets `UiRenderState::resized`, which makes the next
`update` take `redraw_all` rather than `redraw_updates` -- so after a
resume every widget's `draw` runs again. The stale coordinates come from
one cache further in: `TextView::render` (`iris/src/widget/text/mod.rs`)
returns its cached `RenderedText` whenever the wrap width, buffer and
attrs are unchanged, so `TextData::place` is never reached, no glyph is
re-rasterised into the fresh atlas, and the *previous* atlas's
`uv_min`/`uv_max`/`layer` go straight back to the GPU. Text whose content
changed after the resume -- the diagnostics pane Iris tapped -- re-shapes
and is therefore perfect, which is exactly the split in her screenshot.
The fix is one mechanism: a `generation` counter on `GlyphAtlas`, bumped
by `clear`, recorded on each `RenderedText`, added to `TextView::render`'s
cache key, with a `debug_assert_eq!` in `Painter::glyphs` that a submitted
quad's generation is the live one. Test
`clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it`, run and
passing; **still needs phone-side confirmation**, since no emulator here
has a Vulkan adapter and the GLES path may not destroy the surface at all.
**Item 2, the keyboard would not reopen -- fixed and confirmed.**
`attr.rs`'s `on_press`, already-focused branch, now calls `focus_gained`
on a tap that stays inside `DRAG_SLOP`, which is what Android's own
`EditText` does (`showSoftInput` is idempotent). Emulator, 2026-09-07:
first tap `mInputShown=true`; back gesture; second tap `mInputShown=true`
and the composer rises again. **Negative control run**: with that one call
removed and nothing else changed, the second tap leaves
`mInputShown=false` -- Iris's report exactly. **The case the fix had no
reason to touch**, also run: a horizontal swipe across the focused
composer and a vertical swipe out of it both leave `mInputShown=false`, so
her earlier "if I swipe over the input bar it brings up the keyboard" has
not come back.
**Item 3, the IME height -- fixed and confirmed.** `MainActivity.java`
sends `getInsets(ime()).bottom` *and* `isVisible(ime())` as two separate
values (the height used to be sent as the boolean 1/0, which is why
nothing could pad by it); `Insets`/`WindowInsets` carry both, and
`bench_client.rs` reads the boolean for its state machine and the height
for `Composer::set_bottom_inset`. The list follows for free -- it is
`.height(rest(1))` in the same `Span` as the composer bar, so the bar
growing shrinks the list. Emulator, 2026-09-07:
`iris insets: ... bottom=883 ime_bottom=883 ime_visible=true`, and the
composer's box moves from `31,2277..1048,2329` to `31,1457..1048,1509` --
820px, which is 883 less the 63px navigation bar it was already clearing.
Screenshot checked: the transcript ends above the composer, which sits on
the keyboard.
**Item 1, the fling -- two more defects behind the first, all three
fixed here; the phone is what settles it.** The velocity half is what the
report predicted: `on_touch_event` read only each `MotionEvent`'s final
position, so a batched 120Hz flick fed the tracker one sample and
`velocity()` answered 0.0. It now replays every historical sample
(`getHistoricalAxisValue`/`getHistoricalEventTimeNanos`) through the
sensor pass, `CursorState` carries the sample's *own* time (so a replay
loop's speed cannot become the measured velocity), and the press itself is
a sample, as Android's own `VelocityTracker` does with `ACTION_DOWN`.
`iris drag release: samples=… span=…ms v=… outcome=…` logs the decision.
Then the emulator showed the two the report could not have known about:
1. **Nothing ever advanced a fling.** `List::fling` sets the state;
`tick_fling` moves it; and `tick_fling`'s only caller in the workspace
was `bench_client.rs`'s own fling phase, which drives it in a loop.
So the benchmark flung and a finger never did -- and the earlier
"verified flinging on the emulator with `render()` counts" was that
benchmark measuring itself. Measured before the fix: frames stop on
the same millisecond as `iris drag release`. iris now has one
animation mechanism -- `Widget::tick(now) -> bool`, ids registered
with `UiData::animate`, drained each frame by
`UiData::tick_animations`, which both backends call before the draw
and re-request a frame from while it answers true. `List::tick` is
`tick_fling`; `Selection::drag` registers on `Released(Some(v))`.
Test: `a_registered_fling_is_driven_by_tick_animations_and_then_
unregisters`, confirmed to fail without the registration.
2. **The fling lasted 45 seconds.** Visible only once flings animated at
all. Two causes, both in `FlingCalculator`: `List::fling` hardcoded
`FlingCalculator::new(1.0)` while the velocity it is fed is in
physical pixels (`List` reads `painter.density()` now), and
`physical_coefficient` multiplied by `FLING_FRICTION` (0.015) where
AOSP multiplies by its own tuning constant **0.84** -- a coefficient
56x too small, put through `exp(ln(…)/(rate-1))`. Every existing test
compared the calculator with itself (monotonic, signed, integrates to
the closed form) and so passed throughout;
`a_flick_lasts_what_aosps_own_formula_says_it_does` pins the absolute
numbers against AOSP's formula worked by hand. Emulator after both:
release at `v=11064`, frames for **1.62s**, then none -- against
AOSP's own 1.586s for that velocity at density 2.75.
**What Iris should look for on the phone**: `adb logcat | grep "iris
drag release"`. `samples=1` or `span=0.0ms` means the historical
replay is not reaching the tracker on her device; a sensible
`samples`/`span` with `v=` in the thousands and `outcome=Released(Some
(…))` means the gesture is measured correctly and anything still wrong
is downstream of it. `outcome=Tapped` means the flick never crossed
the slop.
**Two things found on the way, both pre-existing at `ba2afba`.**
- **`MOVE_CHAIN_LIMIT` was 16 and the composer's chain is 17.** Tapping
the composer in any debug build aborted on `resolve_move_chain`'s
assert; in a release build (what Iris runs) the walk simply stops
summing, on the CPU *and* in shader.wgsl, so a widget past the bound
draws and hit-tests short by whatever the outer slots held, with
nothing on screen to say so. Both constants are 64 now, and the assert
prints the chain (`64(0, 0) -> 63(0, 0) -> … -> 0(0, 0)`) so a cycle
and an honestly-deep tree can be told apart -- which is how this one
was: 17 distinct slots.
- **`minSdk` is 29**, up from 26. `getEventTimeNanos` and
`getHistoricalEventTimeNanos` are API 29, and a missing JNI method
there is a hard crash on the first touch rather than a degraded fling.
`build-apk.sh`'s `cargo ndk -P` matches.
Still open and **pre-existing**: the composer bar's grey background is not
drawn on the `transcript-screen bench` build, so the transcript shows
through where the bar should be (`Stack{StackSize::Child(1)}` is the thing
to look at). Unchanged by any of the above.
### Task A, closed 2026-09-06: the composer scrolls on a finger
`iris/transcript-ui/src/composer.rs` is `field.scrollable().masked()` now.
Verified on this checkout's emulator -- the evidence and the numbers are
in docs/IRIS_TODO.md's ticked "composer has no touch-drag scroll" item.
**The premise the task was given under was wrong, and that is worth
recording**: `Scroll` did *not* measure against the window. Its
`used.within_len(container).to_abs(output_size)` came to exactly
`abs + rel * container_px` -- the right number by a route that reads as if
the window were the container, which is what cost a session. It is
`painter.px_size()` and `to_abs(container_len)` now: same arithmetic,
stated the way the invariant is. `Scroll` also still reports its
**content's** size upward, deliberately -- reporting the container makes
the answer a function of itself (the bar is sized *from* that report, so
it collapses to nothing and never recovers; measured in the headless
harness before the shape was settled).
What actually broke the composer was three separate defects, each now
carrying a headless regression test in `iris/src/layout_tests.rs` that was
confirmed to fail without its fix:
1. **A `MaxSize` reported its cap as an unresolved `dp`.**
`Span::draw` places a child from the `abs`/`rel` of the length it
reported, so `dp(168)` was worth **zero** and the bar got a slot of
nothing the instant its content passed six lines; the `Scroll` inside
then measured its container at **-63px** (the padding subtracted from
nothing) and panned the whole message out of view. Emulator log, before
the fix: `container=-63 content=415.8 amt=478.8`. Fixed by
`Len::fold_dp` (new), used by `MaxSize` and `Sized` on the way out, and
guarded for every widget by a `debug_assert!` in
`UiRenderState::draw_inner` that a reported `Size` carries no `dp`.
Test: `a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it`.
2. **A `Masked` allocated a fresh mask slot on every draw.**
`draw_inner`'s unchanged-region fast path means its descendants are
mostly *not* redrawn with it, so they kept clipping against the slot
they were first drawn under -- measured on the composer's tree at
**four live mask entries, none of them the widget's current box**, and
the field drew nothing at all. The slot is allocated once and rewritten
in place now (`ActiveData::own_mask`, `Painter::set_mask`), with its
path out in `remove`'s `undraw` branch. Test:
`a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region`.
3. **A panned widget's own hit box moved twice.** `mov` updates
`active.region` *and* accumulates the same delta on the widget's move
slot, and `resolved_region` added both -- so after a finger pan the
composer's field was untappable, while its descendants were fine (which
is why `hit_testing_follows_a_scrolled_widget`, which checks a
descendant, never saw it). `ActiveData::move_applied` records the part
of the slot's delta `region` already accounts for. Test:
`a_panned_widgets_own_hit_box_moves_exactly_once` (fails at exactly
2x the pan without it).
Still open, and **pre-existing** (present in the build before this change,
so not the scroll area's doing): the composer bar's grey background is not
drawn on the `transcript-screen bench` build, so the message reads as
white text over the transcript. `Stack{StackSize::Child(1)}` is the thing
to look at.
Rig fix on the way past: `iris/android-app/run-bench.sh` polled logcat for
`"iris bench report:"`, which `copy_report` also logs at startup
("nothing to copy -- run the benchmark first"), so it returned instantly
and printed a report that had never been run. It polls for the report's
own first line now.
### Task B, closed 2026-09-06: a streamed delta costs one markdown block
A transcript row was one `TextEdit` holding the whole message, so every
delta re-shaped every paragraph of a long reply through parley -- the one
phase where iris trailed Compose on Iris's phone. A row is a **column of
one `TextEdit` per top-level markdown block** now, and a delta that lands
in the last block is one `set_with_spans` on that block.
- **`client-core/src/markdown_blocks.rs`** is the split: `split_blocks`
(top-level blocks with their source, via the same `pulldown-cmark` the
renderer parses with, so the two cannot disagree about where a block
starts) and `common_prefix`. Seven tests, including the one that says
the fast path must **compare** rather than assume: appending `---` under
a paragraph turns that paragraph into a heading, so an already
laid-out block is not always still what it was.
- **`iris/transcript-ui/src/row.rs`** builds the column and owns
`RowBlocks::apply_delta`; **`lib.rs`** keeps the *tail* row's blocks
(`TranscriptScreen::tail`) since that is the only row a delta reaches.
- **A block is the selection unit**, not a row: `Selection` is keyed by
`SelKey = (RowKey, u32)`, which compares in reading order at both
levels so every range query in that file is unchanged. The list-level
(pointer-captured) half of a drag resolves the block under the finger
from its drawn box (`Selection::locate`) instead of doing arithmetic
from the row's extent.
**Pass condition, met**: `a_delta_into_a_long_reply_redraws_the_same_widgets_as_a_short_one`
(`transcript-ui/src/lib.rs`) drives a real `UiRenderState` and asserts the
`Widget::draw` count for one delta into a 100-paragraph (3,000+ character)
reply equals the count for the same delta into a one-paragraph reply.
**30 either way.** It is a real test, not a tautology: it read **630
against 30** at three points on the way -- once because `Span`'s measure
pass redrew every child, and once because `build_tree` did not seed
`tail`, so the first delta after opening a screen took the rebuild path
with nothing on screen or in `take_rebuilds()` to say so.
Two things tried and dropped, so the next session does not redo them.
`Painter::measure` (a container asking a clean child for its size instead
of drawing it provisionally) fixed one of the 630s but the test passes
without it once the `tail` seeding is right, so it was removed rather than
kept on speculation. And the emulator's own numbers say the remaining
cost is not in the block split.
**Verified on the emulator** beyond the counter: the transcript draws its
blocks with their own spacing (heading, prose, fence), and
`ui-trace record --do "holddrag 300 700 700 1000 700 600"` logs
`iris selection: begin at row (3187, 0)` then `extend to row (3187, 1)`
with the highlight crossing from the heading into the code block -- a
selection that spans blocks, which is what the re-key had to keep.
### Bench, stream phase, before and after Task B (emulator, 2026-09-06)
`iris/android-app/build-apk.sh debug --abi x86_64 --features
"transcript-screen bench force-gles"` + `run-bench.sh`, this checkout's
AVD. Emulator absolutes transfer nothing; the before/after ratio on the
same emulator does.
Same AVD, same fixture, same build flags, 20 minutes apart. Emulator
absolutes transfer nothing; the ratio does.
before after
stream: 202 frames over 21.0s stream: 293 frames over 21.0s
late: 197 (97.5%) late: 285 (97.3%)
p50 61.5ms p50 54.5ms (-11%)
p90 211.7ms p90 113.1ms (-47%)
p99 342.6ms p99 137.4ms (-60%)
worst 403.6ms worst 143.0ms (-65%)
The tail is where the whole-message re-layout lived, and it is where the
change shows: 91 more frames delivered in the same 21 seconds. The p50
moves least, which is consistent -- a delta into a *short* message never
cost much. **The phone number is Iris's to take**; nothing here is a
statement about her device.
### Verification pass over Tasks A and B, 2026-09-06
Read of `git diff fb6b459..HEAD -- iris/ client-core/` against LAYOUT.md,
TEXTURES.md, IRIS.md/DECISIONS.md's 2026-09-06 entries and CODE_RULES.md,
with the emulator. **Verdict: deliverable to the phone.** One real defect
found and fixed, two missing guards added, one open item closed as stale.
1. **The block model is correct.** `split_blocks` was checked against the
shapes a real transcript has -- a fence with blank lines, a `---`
inside a fence, a nested list, a fence directly under a heading, a
table, a quote -- and against the property `apply_delta` rests on, at
**every character boundary** of a message containing all of them:
growing a message may rewrite its last block and never an earlier one,
or `common_prefix` says so. No defect (`client-core`'s
`every_prefix_of_a_streamed_message_keeps_all_but_its_last_block`,
commit `a56a928`). A delta closing a fence, a delta mid-word and a
stream ending inside an unterminated fence are each their own test.
2. **`iris/core/src/ui/render_state.rs`, `draw_inner`'s size-independent
fast path: fixed** (commit `e63e923`). It rewrites the widget's own
primitives in place and writes **no** move-slot delta, so unlike `mov`
there is nothing for `move_applied` to count; `167862c` counted one
anyway, and `resolved_region` then subtracted a distance the chain
never held. Every such widget's hit box sat short of its drawing by
its last step, with the drawing correct -- nothing on screen to say
so. `Span` reaches this on the **first frame** of any tree containing
a `Rect` (the `.background(rect(..))` idiom, list row tints), because
it measures each child at the full region and then places it. Pinned
by `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`,
the sibling of `a_panned_widgets_own_hit_box_moves_exactly_once` on the
branch that fix had no reason to touch.
3. **Selection across blocks is sound; its rebuild path had no test**
(commit `155d899`). `SelKey = (RowKey, u32)` orders lexicographically,
which is reading order at both levels, so `begin`/`extend`/`locate`
and the range queries carry over unchanged; `selected_text` joining
with a blank line is right for blocks as well as rows, since that is
how markdown separates them. The gap was the tail rebuilt under the
**same key with fewer blocks** -- the dropped blocks keep pointing at
widgets `replace_back`'s drop frees, and `Selection::begin` resolves
every registered handle on an ordinary press, so the next tap anywhere
panics. `e1030d6`'s unconditional `unregister` is correct and now has
`a_tail_rebuilt_with_fewer_blocks_leaves_none_of_them_in_selection`,
confirmed to fail (3 blocks still registered, expected 1) without it.
`Selection::registered_blocks` is the test-only accessor that lets it
assert the contract rather than only that nothing panicked.
4. **The three new `debug_assert!`s are whole-set, not one member.**
`Len::fold_dp`'s is in `draw_inner` after *every* `Widget::draw`, so
it governs the set by construction; `Pad` and `Span` were checked and
already fold through `apply_rest`, and `Sized`/`MaxSize` are the two
that reported a caller-written `Len` raw. `own_mask`'s reuse lives
inside `Painter::set_mask` itself, whose only caller is
`widget/mask.rs`. `move_applied` has exactly two writers, `mov` and
`reposition` (now one, after finding 2), and `resolved_region` is the
only reader -- `window_region` goes through it.
5. **The O(last block) claim now holds for parley, by counter**
(commit `c3cfc67`). `take_counters` gained a fourth number, text
shapes, bumped in `Painter::render_text` -- which `TextView::render`
only reaches on a cache miss, so it counts shapes and not requests. A
draw counter cannot stand in for it either way. Measured: **one delta
into a 100-paragraph reply shapes exactly 1 text layout, the same as
into a one-paragraph reply.**
6. **The composer bar's grey background *is* drawn** -- IRIS_TODO.md's
"still open, and pre-existing" note is stale and has been corrected.
Measured by decoding the screencap rather than eyeballing it: the bar
is `rgb(41,40,49)` (the declared `40,40,46` after sRGB rounding),
**full width, y2245..y2365** on the 1080x2424 AVD, with the field at
`31,2277..1048,2329` and the 63px nav strip below. Whatever the note
saw, Task A's `MaxSize`/`own_mask` fixes closed it.
**Checks run**: `cargo fmt --all --check` clean in both workspaces;
`cargo clippy --workspace --all-targets` warning-free (only the
pre-existing future-incompat note about `wgpu`/`naga`/`winit`);
`cargo test` 81 (iris) + 13 (iris-core) + 20 (transcript-ui) + 123
(client-core), all passing. One bench run on this checkout's AVD with the
assertions live, debug x86_64 `force-gles`, no abort and nothing in
logcat: **stream: 298 frames over 21.0s, late 287 (96.3%), p50 52.8ms,
p90 108.1ms, p99 137.3ms, worst 148.9ms** -- reproducing the "after"
column above.
- [x] **Merge the `DragGesture` work** -- done 2026-09-06 (merge commit
`f802de9`, `git merge --no-ff worktree-agent-a754368325fa06839`,
clean, no conflicts across the 8 files `e12c708` touched). Targets
two of the four bench-v2 defects: finger flings dropped by
per-widget hit testing (pointer capture + `CursorSense::Drop`), and
IME insets never redelivered (`MainActivity.java` edge-to-edge).
**Tap-vs-swipe/`DragGesture` overlap, reasoned through**: `attr.rs`'s
`on_press` (composer focus) and `sense.rs`'s `DragArbiter`/
`DragGesture` (list pan-vs-select) do not share a mechanism, but
they don't need to -- `on_press` never calls `capture_pointer`, so
it only ever sees an ordinary per-frame hit-tested `Pressing`/
`PressEnd` (`run_sensors`' `region.contains(cursor.pos)` check,
unaffected by capture unless *this* widget requested it), the same
as before `DragGesture` existed. The two only interact where a
gesture starts on the composer and travels into the list's region;
`run_sensors` already delivers `Pressing` to whichever widget's
*current* position contains the pointer, so `List` starts getting
frames the instant the finger crosses the boundary -- with no
`PressStart` of its own, which is exactly what `DragArbiter::
is_idle()`'s 2026-09-05 recovery branch exists for. No consolidation
needed; `DRAG_SLOP` is already the one shared constant (`attr.rs`
imports it from `sense.rs`, not a second copy).
**Checks, 2026-09-06 merge pass**: `cargo fmt --all` clean;
`cargo clippy -p iris -p iris-core -p transcript-ui --all-targets`
and the same for `-p desktop-app -p tabs-ui`, zero warnings beyond
the pre-existing external-crate future-incompat notice
(naga/wgpu/wgpu-core/wgpu-hal/winit); `cargo test --lib -p iris -p
iris-core -p transcript-ui` and `-p desktop-app -p tabs-ui`, 97
passed/0 failed, including the review-fix tests below.
`cargo test --workspace`/`cargo clippy --workspace --all-targets`
(the full-workspace forms, which also build `iris`'s winit examples)
were abandoned after 40+ minutes each stuck compiling one example
binary with `uptime` reading a load average of 66-78 on this 8-core
VM (3-4 concurrent peer `cargo`/`cargo check` invocations the whole
session) -- `ps -o time` on the stuck `rustc` showed 2 seconds of
accumulated CPU time after 38 minutes of wall time, confirming
scheduler starvation rather than a hang. The per-package `--lib`
form above is what actually exercises the changed code and finished
in under 4 minutes warm. `android-app` (`iris-android-app`) is
excluded from the host workspace (`iris/Cargo.toml`, needs the NDK
target) and is covered instead by the APK build below, which
compiles it for `x86_64-linux-android`.
**Emulator checks, 2026-09-06** (this checkout's `ai-app-2` AVD,
`iris/android-app/build-apk.sh debug --abi x86_64 --features
"transcript-screen bench force-gles"` -- plain Vulkan crashed on
this AVD's boot this pass, `wgpu_core::instance: enabled backend
Vulkan has no adapters`, unrelated to this merge and worked around
with `force-gles` the way I5's own box already documents for this
hardware):
- **(a) tap-vs-swipe still holds.** Fresh app launch, `dumpsys
input_method`'s `mInputShown=false` at rest. `ui-trace record
--do "swipe 540 1510 540 700 200"` (a swipe starting on the
composer's own box, read from `ui-trace show -m Message --field
box` as `31,1488..1048,1540`) leaves `mInputShown=false` and the
box unmoved (no keyboard-driven resize). `ui-trace record --do
"tap 540 1510"` on the same field then reads `mInputShown=true`.
Matches `20b1225`'s original result -- the `DragGesture` merge
did not disturb it, confirming the reasoning above.
- **(b) a real finger fling keeps the list moving after release.**
Screenshot-hash sampling (`adb exec-out screencap`, `md5`, since
transcript rows carry no per-row accessibility label yet -- I5's
own leftover -- so `ui-trace show` cannot track them) at ~40-60ms
intervals through and after a fast `swipe 540 1400 540 400 120`
(with room to scroll confirmed by a preceding slow drag) caught
two *distinct* post-release frames in one run (a settle-position
beyond the raw drag's own last frame), and every run showed
28-32 `iris::android::view: render()` log lines per gesture
against an idle baseline of 0 in 1.5s and roughly 8 expected from
a bare 120ms drag's own `Pressing` frames alone -- i.e. redraw
kept being requested well past the finger lifting, which only
happens while `List::tick_fling` is still returning `true`.
Some runs' screenshots showed only the drag's own jump with nothing
further *visibly different*, which is consistent with a real but
small/fast-settling fling (a modest synthetic-touch velocity's
spline tail moves little per frame) rather than absence of one --
the render-count signal did not vary between those runs and the
one with a visible second frame. Recorded as confirmed, with that
caveat, rather than measured to a number; a phone verification
(Iris's own report closes this properly) is still open per
`IRIS_TODO.md`'s item.
- **(c) `on_insets_changed` fires on an IME toggle, with confirmed
cycles.** `run-bench.sh`'s report: `keyboard: shown 4/5, hidden
5/5 (confirmed via on_insets_changed)` -- the "could not be
shown" unknown-state line (`bench_client.rs::run_keyboard_phase`)
did not fire, unlike the pre-`DragGesture` build this same report
format existed for.
Worktrees removed after the checks above: `agent-a754368325fa06839`
(the source branch, its own emulator stopped first via `cd` into
it + `emu down`), `agent-a27094a7db775552a`, `agent-a1ff0294b6c29127e`,
`agent-a9002910a315fe719` -- each confirmed `git rev-list --count
rustify..<branch>` = 0 and no uncommitted changes first; their
branches deleted too. `agent-a16b22e34539b810e` and
`agent-a6e37a2335f436d08` left alone -- both `git worktree list`
`locked` to a live peer agent.
- [x] **Fix `docs/REVIEW-2026-09-06.md`**, done 2026-09-06, after the
merge (finding 1's shape and location in `selection.rs`/`lib.rs`
were unchanged by the merge, which touched `Selection` but not
`apply`'s `Rebuild` arm). All ten findings fixed -- new
`Selection::clear()` for finding 1 (the simplest option the review
named: clear the same way `List::clear()` clears the list, let
`push_row` re-`register` survivors), five `debug_assert!`s
(2-5, plus 7's restructure), and three new tests (8, 9, 10),
confirmed with the `apply_tests::a_row_dropped_by_a_regroup_does_
not_outlive_itself_in_selection` test passing (it exercises exactly
finding 1's shape: build a real `TranscriptScreen`, force the same
regroup `diff_tests` already covers, `apply`, then a surviving
row's `begin` -- panics pre-fix, per the review's own test-8 ask).
`docs/IRIS.md`'s 2026-09-05 entry gained the line the review's
"Docs" section asked for. See `docs/REVIEW-2026-09-06.md`'s own "Fixed, 2026-09-06"
section for the per-finding account. Committed together with the
review file.
- [~] **Iris's 11:39 phone report on the 02:07 build** (four items,
verbatim in `IRIS_TODO.md`'s "From the phone, 2026-09-06, 11:39"):
composer floating two thirds down the screen at launch with black
below it; a swipe starting on the composer held until the finger
leaves it; no fling (expected, `DragGesture` unmerged); text still
lost on app-switch on the phone despite the emulator-verified
atlas reset. The first and last are the same class as the next
box and go to that agent; the middle two are the merge box's.
- [~] **Stale primitives and invisible composer text**, 2026-09-06:
**typed text is fixed and was never a renderer bug at all**; the two
duplicate-drawing halves are **not reproducible** on this checkout's
emulator any more and are recorded below with what changed.
**1. Typed text (P0 box item 2, `IRIS_TODO.md`'s own item) --
fixed.** The composer's buffer was empty the whole time.
`TextEditCtx::select` (`iris/src/widget/text/edit.rs`) compared the
tap against the *laid-out text's* box and set `selection = None` for
anything outside it; an empty field lays out to a zero-width box, so
tapping an empty composer granted focus and opened the keyboard with
no caret, and `insert_str` returns early without one -- every
keystroke was dropped in silence. **Gboard's suggestion strip is
Gboard's own composing state, not a read of our buffer**, which is
what made the earlier pass conclude the buffer held the text and
send the search downstream into the renderer; the `accessibility`
dump saying `text=""` for the `Message` node was the first
contradicting evidence. Parley clamps a point outside the layout by
itself, and a press reaching `select` has already been hit-tested to
the widget, so the "outside" branch had nothing left to mean. Three
new tests in `edit.rs`, one of which fails on the pre-fix code, plus
a `debug_assert!` in `insert_str` so an insert with no caret fails at
the mistake rather than dropping input -- it caught
`layout_tests::composing_text_after_a_keyboard_resize_...` typing
into an unfocused field the moment it was added. **Emulator
evidence**: `ui-trace record --do "tap 'Message'"` then `adb shell
input text` shows the text in the bar with a caret
(`/tmp/final-typing.png`) and logs `iris text render: chars=5 ...
glyphs=5`, against `glyphs=0` per keystroke before.
**2. The header drawn twice after a keyboard resize (this box's own
"(a)") no longer has a path to happen on this emulator, for a
measured reason**: since `MainActivity.java` went edge-to-edge
(`e12c708`), **opening the keyboard no longer resizes the surface at
all**. Measured: `render()` reports `out_size=(1080, 2282)` unchanged
across an IME open, while the new `iris insets:` line reports
`bottom=63 ime_bottom=0` -> `bottom=883 ime_bottom=1`. So the IME is
an inset now, not a `surface_changed`, and the two-phase `Span::draw`
the duplicate was blamed on is not re-entered. Reproduction attempts
this pass, all negative: `tap 'Message'` + `ui-trace elements`
(exactly one "Run benchmark" in every frame of the trace), a
screenshot with the keyboard open, and a real `adb shell wm size
1080x2200` *while the keyboard was open* (a genuine
`surface_changed`) -- one header row, no stray copy
(`/tmp/resize-dup.png`).
**3. The `Compacted:` row drawn twice on Iris's phone is still
open**, and nothing here reproduces it. What was ruled out this
pass: the widget arena (`list.rs`'s
`replacing_the_last_row_many_times_does_not_leak_primitives`), the
`top_bar` rebuild (`last_top_pad`, a previous pass), and now the
keyboard-resize trigger above. One real defect *was* found by
reading the path and is fixed, though it cannot be shown to be her
bug: `UiRenderState::draw_started` -- the guard whose whole job is
"do not redraw a widget an ancestor is drawing right now, or one of
the two copies is orphaned" -- **tested its own set after removing
the id from it**, so the test was constant `false` and the guard
could never fire, while the set grew by one entry per widget ever
drawn and was never emptied. It is now inserted around
`Widget::draw` and removed when it returns, with a `debug_assert!`
at the top of `update` that it is empty between frames. Her build
has both.
- [x] **Stale primitives, the phone's half** — **root-caused and fixed
2026-09-06, commit `76b1f99`.** It was neither `Span::draw` nor
`List`: `UiRenderState::draw_inner` *read* `needs_redraw` without
consuming it, and used it to skip the whole `if let Some(active)`
block — **including the `remove(id, false)` that frees a redrawn
widget's previous primitives**. So a widget that was both already
active and marked dirty, and was reached by an **ancestor's** draw
rather than by `redraw_updates` picking it first (the order a
`HashSet` makes arbitrary, which is why it was intermittent), wrote
a second full set of primitives and then had `active.insert`
overwrite the only handles that could ever have freed the first
set. Those instances stay in the layer's buffer for the life of the
process, with a leaked move slot and leaked mask refs, redrawn every
frame at whatever region they last had — and `List` sets no mask, so
a row measured at `GENEROUS_PADDING` leaves its ghost outside the
list's own box, which is the copy below the composer.
`Painter::draw_twice` (`List::place`'s measurement pass) reaches
`draw_inner` twice for one id in one frame and so hits the same
fault with no ancestor involved.
**Fix**: consume the mark at the top of `draw_inner` — this call *is*
the redraw it asked for — and free the old primitives on the dirty
path too.
**Why the earlier passes could not see it**: `replacing_the_last_row_
many_times_does_not_leak_primitives` counts *widgets*, and the
orphan's owner is very much alive; it is an earlier set of that same
widget's primitives that is stranded.
**Guard**: `UiRenderState::orphaned_primitives()` names every live
instance no `ActiveData` owns, and `update` `debug_assert!`s it empty
every frame in debug builds. The per-frame form is a *count*
comparison (`primitive_counts_agree`, O(active widgets)); the
O(primitives) walk only runs to build the failure message, because
running it per frame made a debug build on the emulator too slow to
finish a bench run at all (260s timeout, no report).
**Test**: `an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy`
(`iris/src/widget/list.rs`), which fails on the pre-fix code with
`1 primitive(s) survived their own widget's redraw`.
**Emulator evidence, 2026-09-06** (this checkout's `ai-app-2` AVD,
`build-apk.sh debug --abi x86_64 --features "transcript-screen bench
force-gles"`, a **debug** build so the guard is live): a complete
`run-bench.sh` run — 3,142 frames over 147s across the fling, the
400-event stream (which is 400 `apply` calls including the fixture's
compaction event), the typing and the keyboard phases — with the
assert firing zero times and `logcat` showing no abort. That is the
whole of the phone's reported scenario exercised with the invariant
checked on every frame.
- [~] **Composer touch-drag scroll** for overflowed text — **the
mechanism is done, the composer is not.** `Scroll::drag`
(`iris/src/widget/position/scroll.rs`) takes its pan from the same
`sense::DragGesture` `List` is driven by, and
`WidgetLike::scrollable()` registers it beside the wheel handler, so
every `.scrollable()` in the codebase pans on a finger with nothing
added at the call site. No fling (`Scroll` has no per-frame tick and
the areas it wraps are at most a screenful) — see IRIS.md and
DECISIONS.md. A vertical drag inside a *focused* field no longer
extends a selection either (`attr.rs`'s `on_press` now applies the
same `DRAG_SLOP` rule its unfocused branch already did), which is
Android `EditText`'s own behaviour and what lets the scroll area
around a field win the gesture.
**Tests**: four in `scroll.rs` (pan past the slop, a tap inside it,
a horizontal drag, the end clamp) plus
`a_finger_drag_over_a_scroll_area_pans_it` in `sense_tests.rs`, which
drives the whole path — `scrollable()`'s registration, `run_sensors`'
dispatch, `Scroll::drag`, arbitration and pointer capture — and fails
with `got 0` if the registration is removed.
**What is left, with the measurement**: wrapping the composer's field
in `.scrollable().masked()` was tried and reverted the same day.
`Scroll` resolves `content_len`/`container_len` against
`Painter::output_size` — the whole window — so inside the `MaxSize`
that caps the composer at six lines the two are in different spaces
and the field pans itself entirely out of the bar: measured on the
emulator with 474 characters in it (`iris text render: ...
size=(1016.7, 623.7)` against a 441px cap) the bar collapsed to its
padding with no text in it. Making `Scroll` measure against its own
offered box is the next step, and it touches a widget the transcript
and the bench shell both use.
One real bug **was** found and fixed on the way (`ActiveData::mask`
stored the mask a widget *set* rather than the one it was drawn
*under*, and `redraw` feeds that field straight back in as the
inherited mask — so a targeted redraw of any `Masked` handed it its
own mask and aborted on `set_mask`'s nested-mask assert; that is a
real abort on the emulator, `assertion failed: self.mask ==
MaskIdx::NONE`, reproduced as
`redrawing_a_masked_widget_does_not_nest_its_own_mask` in
`layout_tests.rs`).
- [ ] **Streaming re-layout** (IRIS_TODO.md's last section) — after the
above, since they make the stream phase unrepresentative today.
- [x] **client-core prerequisites for P1, in parallel** (pure Rust,
disjoint from `iris/`), closed 2026-09-06: `TranscriptSource`'s
cache-vs-server stitching (new `client-core/src/transcript_source.rs`)
and `joinPages`/`healSplitMessage`/`adoptRun` page-boundary healing
(new functions in `transcript_fold.rs`), per `CLIENT_CORE.md`. Ported
against the Kotlin source and AGENTS.md's paging incidents as the
spec (`TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit
tests of their own to port test-for-test). `client-core` goes from
85 to 109 tests; `cargo test`/`clippy --all-targets`/`fmt` all clean.
Both AGENTS.md regressions have a dedicated test: `loadOlderPage`'s
`before == 0` guard moved into `TranscriptSource::page` itself
(`paging_before_the_first_event_makes_no_request_at_all` asserts
zero transport calls, not just an empty result), and
`a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run`
pins `adopt_run` running on *every* join rather than only the
split-call path. One incidental fix needed to port `TranscriptSource`
faithfully: `api.rs` gained `fetch_transcript_lines` (additive, the
existing `fetch_transcript_page` untouched since `iris/` depends on
its signature), which pairs each event with the exact server bytes
it came from via `serde_json::value::RawValue` rather than
re-serializing a parsed `Value` -- needed so the cache and a live SSE
frame agree byte-for-byte, the same class of bug as the
`float_roundtrip` fix. Deliberately not ported: `EventStream.kt`'s
reconnect/backoff and cross-thread stream cancellation, which are
runtime policy for whichever framework embeds this crate, not pure
logic -- see `CLIENT_CORE.md`'s new section for the full account.
- **Then**: redeliver the APK for Iris. **Delivery is a push to the
`~/repos/ai-app-bench` repo** (`iris/build/outputs/apk/release/
iris-bench-arm64.apk` plus a dated README section), which Dev Updater
on the host pulls -- **not** `~/host/bench/`, which nothing reads; two
builds on 2026-09-06 went there and never reached her phone. Record any
choice she should see in `DECISIONS.md`.
## Where things stand (2026-09-05)
- **Streaming no longer costs a full rebuild** (P0's box, "Streaming no
longer costs a full rebuild" subsection): `iris::widget::List::
replace_back`/`clear` plus `transcript_ui::TranscriptScreen::apply`
replace the "refold + rebuild the whole ~3,200-row tree per event" path
in all three clients. Worst/p99 frame time in the streaming phase
dropped roughly 3x on this checkout's emulator (see the box for the
exact numbers and their caveats). Two new scripts,
`iris/android-app/build-apk.sh` and `iris/android-app/run-bench.sh`, now
do the build/install/tap/read-report cycle that used to be typed out by
hand each time.
- **The three items the dropout-fix pass left open are all closed,
2026-09-05** (the `ai-server` build break -- `event_model::
Event::LimitReached` -- was already fixed on `rustify` by the time this
pass started). Three clean `-gpu host` cold-boot `iris-scroll.sh` runs
all scrolled 24/24 swipes (checked directly via clustered `render():`
timestamps, not inferred from frame count), and the host-GPU table's
iris row is now a best-of-three. `EMU_GPU=software` + `force-gles`
still cannot produce a GLES number on this hardware, now for a third,
structural reason found this pass: SwiftShader's ES 3.0 GL path reports
zero storage-buffer capacity, and `shader.wgsl` reads four
`var<storage>` buffers unconditionally -- reaching that path needs a
shader rewrite, not a limits fix, so the SwiftShader-Vulkan-vs-GLES
question is closed as unanswerable on this hardware rather than
answered with a number. A fresh cold-boot `run-bench.sh` reading for
P0's bench build (`frames=690 janky%=62.03 p50=19.6ms worst=62.5ms`) is
in line with or better than the P0 box's three warm-AVD readings, so
that box's "needs a clean cold boot" caveat is resolved too. Full
account in I5's box, "The three remaining I5 verifications, closed
2026-09-05."
- **The intermittent touch-scroll dropout is root-caused and fixed,
2026-09-05.** Not the previously-suspected coalesced first
`ACTION_MOVE` (ruled out) -- a gesture's `ACTION_DOWN` can land on a
row's own padding/gap or its header, which `CursorSense` has no sensor
over, so the widget that ends up handling the gesture only ever sees
`Pressing` frames and `DragArbiter` never gets `press_start`, leaving it
stuck in `Idle` (answers `Undecided` forever) for the rest of that
gesture. Fixed in `Selection::drag` (`iris/transcript-ui/src/
selection.rs`) via a new `DragArbiter::is_idle()` the caller checks to
recover a missed press on the next `Pressing` frame. Four new unit
tests (three in `iris/src/sense.rs`'s `drag_arbiter_tests`, one in
`transcript-ui`'s `selection::tests`, the latter failing on the
pre-fix code). See this box's own "Touch-scroll dropout root-caused,
2026-09-05" subsection for the trace. **The aggregate verification an
earlier pass could not complete (peer-emulator interference) is now
done, 2026-09-05**: three separate cold-`-gpu host`-boot
`iris-scroll.sh` runs each scrolled all 24/24 swipes, confirmed by
clustered `render():` timestamps rather than frame count alone -- see
this same subsection's "Update, 2026-09-05" paragraph.
- **iris no longer requests compute-shader limits it never uses,
2026-09-05.** `adapter.request_device`'s `Limits::default()` asks for
desktop-tier compute limits unconditionally even though nothing in
`iris`/`iris-core` uses a `ComputePipeline` -- confirmed by grep, not
assumed -- which is what crashed `request_device` outright under
`EMU_GPU=software`'s `force-gles` path (SwiftShader's GL reports OpenGL
ES 3.0, no compute at all). New shared `iris_core::device_limits()`
zeros exactly the six compute fields; `rigs/gpu-probe`'s own mirrored
limits were updated and confirm `IRIS DEVICE: ok` on this VM's own
Vulkan and GL adapters. **Verified on-device 2026-09-05**: a cold
`EMU_GPU=software` boot no longer aborts on the compute-limit request
this fix targeted -- adapter selection now succeeds -- but device
creation still aborts, on a different, unfixed limit
(`max_storage_buffer_binding_size`, SwiftShader ES 3.0 has no SSBOs
either); see this box's "The three remaining I5 verifications, closed
2026-09-05" subsection, item 2. See this box's "Fixed, 2026-09-05,
later the same day" subsection (under the software-mode crash it fixes)
and `DECISIONS.md`.
- **Decided 2026-09-05: iris over Masonry**, by Iris, from the host-GPU
numbers in I5's box and E1/E2's findings. See the Recommendation's item
3 and `DECISIONS.md`. Next: the remaining screens and the app on iris —
a new ordered list is the next thing to write into this file.
- **The port plan exists, 2026-09-05: "## The port, in order (decided
2026-09-05)"**, seven steps (P1P7) below "Experiments, in order,"
ordered by risk to the daily-use path rather than by screen count. **P1
— session screen parity — is next.** One crate decision made there:
screens grow out of `iris/transcript-ui` into `iris/app-ui`, with
`desktop-app`/`android-app` as thin entry points over it.
- **I5 is now `[x]`: a clean, single-session, like-for-like 24-swipe
scroll comparison between Compose and iris exists, 2026-09-05.** Same
sandbox session content for both apps, same emulator, `EMU_GPU=software`
(a second pair under `-gpu host` not yet taken). Headline: Compose
(debug build) 1102 in-app-reported frames, 99.0% late, p50 33.8ms/p90
50.6ms/p99 79.5ms; iris (**release** build -- debug `SIGSEGV`s on this
emulator, I4's finding) `FrameReport` 299 frames, 94.65% janky, p50
79.1ms/p90 98.6ms/p99 117.8ms/worst 212.6ms (repeat run: 233 frames,
94.42%, p50 109.3ms). **Not a clean apples-to-apples number**: different
build profiles (forced, not chosen), different jank definitions/frame
populations across the three measurement sources, and both are emulator
numbers under software rasterisation -- all stated plainly in I5's own
box, "Clean scroll comparison, 2026-09-05," which also has the
sampler timeline (load rose during the gesture but did not correlate
with a failure this pass) and the dropout finding (this pass's own
script bug -- `cd`ing into `/tmp` changed which emulator `ui-trace`
targeted -- not a reproduction of the previously-suspected touch-
delivery starvation). `DECISIONS.md`'s DEFERRED item has this table's
numbers for Iris to decide from; the iris-vs-Masonry choice itself is
still hers to make, not decided here.
I5's own box, "Update, 2026-09-05, later the same day" has the full
account.
- **The `-gpu host` pair this box's own DEFERRED item flagged as missing
is now taken, 2026-09-05, and it changes the picture.** Under real GPU
rendering (`--features force-gles`, since the default Vulkan backend has
no adapter under plain host-GPU boot -- confirmed by the exact crash
message), iris's median frame (15.0ms, `FrameReport`) is *faster* than
Compose's (20.0ms, in-app report) on the same session content, the
opposite shape from the software-mode table. The new CPU/GPU split
(`FrameReport::record_split`, `iris/core/src/render/frame_report.rs`,
commit `e2a1fad`) shows why: iris's own redraw-to-submit work is a
median 0.2ms; almost the whole frame is time handing off to the driver.
Software-mode `force-gles` crashes for a third, distinct reason
(SwiftShader's GL path reports itself as ES 3.0, which has no compute
shaders, and iris's device request assumes them unconditionally), so
this pass could not isolate SwiftShader-Vulkan as the sole cause of the
software-mode gap. A real intermittent touch-scroll dropout was also
reproduced and left unexplained (not the same as the earlier pass's
script-bug dropout). I5's box, "Where iris's frame time goes,
2026-09-05, the `-gpu host` pass" has the full account, all four
findings, and what verification did and did not re-run.
`DECISIONS.md`'s DEFERRED item has the updated table.
- **I5's Android integration is done and measured, 2026-09-05.** The
transcript screen runs on-device against a real `ai-server`, with real
scrolling, real touch-drag panning and tap-by-name accessibility all
confirmed by screenshot/log evidence on this checkout's emulator. Two
real, previously-unknown bugs were found and fixed getting here (a
missing `INTERNET` permission, and a background-thread redraw request
that crashed the process via a `Looper` requirement neither this box nor
`Tasks::redraw_handle`'s design had anticipated) -- both in I5's own box,
both in `IRIS.md`.
- **Design choices for the two pieces before this are summarised in
`DECISIONS.md`** at the repo root, which is the file Iris reads for
choices made without her.
- **E4 done, 2026-09-05.** `iris/desktop-app`: a winit window with a
session list beside `transcript-ui`'s screen (`build_tree`), against a
real `ai-server` through `client-core`, enrolled from the same
`aiapp://enroll?...` link a phone scans. Both pass conditions held on
`app/ui-sandbox.sh` -- see E4's own box for the commands, the
screenshot, and a real streaming-duplication bug the screenshot found
and a regression test now covers.
- **The I5 touch-drag pan-vs-select gap is closed, 2026-09-05**, as a
`DragArbiter` in `iris/src/sense.rs` wired into `transcript-ui`'s
selection -- see I5's own box below, "Gap closed, 2026-09-05".
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
keyboard gap — now explained, see below), E2 (a transcript in Masonry,
which found that Masonry has no touch-scroll on Android at all — see
below), E3 (the Kotlin/Java shell over a JNI bridge into Rust, both
pass conditions proved on the emulator — see its own box), E5 (the
Gradle-free packaging xtask, both pass conditions proved — see its own
box), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley +
glyph atlas), I2 (iris on android-view), I3 (`iris::widget::List`), I4
(host half).
- **E5 done, 2026-09-05.** `cargo xtask apk` (new `xtask/` crate at the
repo root, zero dependencies) replaces Gradle for packaging
`app/shellApp`: `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
`apksigner`, signed with the same key `app/build-apk.sh` uses. Both pass
conditions held on this checkout's emulator: `adb install -r` over the
Gradle-built `shellApp` succeeded (same key, so the signatures matched),
and the notification service reached its follow-loop and posted a real
notification while the app was backgrounded. E3's open `kotlinc`
question resolved itself as a side effect of the one Gradle call still
needed for AndroidX dependency resolution — see E5's own box for the
full account, including the one disclosed place Gradle still runs and
what was deliberately left undone (a real-device `arm64-v8a` install,
dex shrinking).
- **I5 — the transcript screen in iris: partial, 2026-09-05 (ticked `[~]`
in its own box, not `[x]`).** `iris/transcript-ui/` builds a real
transcript screen — markdown-folded rows in `iris::widget::List`,
cross-row selection, a growing composer, tool-row expand-hold — on top
of a new, genuinely useful iris capability this box added:
**`SpanStyle`**, per-range text styling (`core/src/primitive/text.rs`),
which is what lets one wrapped, selectable `TextEdit` carry a heading,
bold, italic, inline code and a link all inside the same paragraph —
exactly the inline-rich-text ceiling E2 found Masonry structurally
unable to cross. Screenshotted via `run-headless.sh` (real inline
styling visible, not just block-level). 9 new tests, all passing;
`cargo build/clippy/fmt/test --workspace` and `cargo ndk` (both `iris`
and `transcript-ui`) all clean. **What did not happen this pass**: any
Android integration for this specific screen (no cdylib/Gradle shell
exists for it yet, unlike `tabs-ui`'s `iris-android-app`), and therefore
the emulator-side pass condition (`transcript-bench.sh` against the
Compose baseline, `ui-trace` tap-by-name on a row) — `emu list` showed
the one emulator here held by another session, but the real blocker is
that the integration work itself is unbuilt, not the emulator being
busy. Full accounting, every citation, and the dated IRIS_TODO.md items
are in I5's own box below. **Update, 2026-09-05, same day**: touch-drag
panning over a row's own rendered text, which was not yet reachable for
a specific, diagnosed reason (it competed with this box's own
row-level drag-select for the same gesture, not an absent primitive),
is now closed — a `DragArbiter` in `iris/src/sense.rs`, wired into
`transcript-ui`'s selection — see the box's "Gap closed" note. Android
integration is the one item left before this box can tick `[x]`.
- **E3 done, 2026-09-05, and unlike E1/E2 it is committed to this repo**
(`android-shell/` — a JNI-bridge crate on `client-core` — plus a new
Gradle module `app/shellApp/`, left deliberately separate from
`app/androidApp` so its ~13,000 lines of working Compose UI are
untouched). Both pass conditions held: a notification arrived in
Android's drawer while the app was closed, and a shared text share
landed as a real `userMessage` in a sandbox session's transcript. Found
and fixed three real bugs along the way — a generic `JObject` native
parameter silently exporting the wrong JNI signature
(`UnsatisfiedLinkError`), a class-by-name lookup failing from this
crate's own background thread because a Rust-attached thread has no app
`ClassLoader` (`Error::NoClassDefFound`, invisible without a logger
installed), and `onStartCommand` opening two `/notifications`
connections per enrollment — the last one a latent bug in
`Notifications.kt` itself, found here rather than there. See E3's own
box for the full account, the exact commands, and what was deliberately
cut (attachment uploads, a session picker, the on-screen/banner
suppression — all pending E4's screen).
- **I4 — accessibility names via AccessKit: host half done and verified
2026-09-05, ticked in the box below.** `iris_core::ui::access::AccessTree`
builds one flat AccessKit tree from `Widgets::named()` (a side set only
`.label()` populates, so an unnamed widget costs this nothing), pushed
through `accesskit_winit` on the desktop and `accesskit_android` on
Android, updated only when a name/role/bounds actually changes (a
counter confirms it: 1 rebuild on first draw, 0 across an unchanged
frame, 1 more after a real move). E1's detach-abort mitigation is
carried (`android/access.rs`'s `raise_if_enabled`). Every check that
doesn't need the emulator is clean — see I4's own box for the exact
numbers. **What's left**: the emulator itself is held by another session
this pass, so `ui-trace record --do "tap 'pad'"` against
`iris-android-app`'s tabs screen (which now has five named buttons) has
not been run for real yet — exact commands at the bottom of I4's box.
- **E2 done, 2026-09-05, and its headline finding changes what "decide
from the measurements" (recommendation item 3) can mean right now.**
Built a real transcript screen (`~/src/android-view/e2-transcript`,
local, not committed — see E2's own box), fetching 854 real events from
an `app/ui-sandbox.sh` session through `client-core`. Six of the seven
"hard to get back" behaviours are answered with evidence either way;
the seventh (measurable frames) is **blocked before it can even start**:
neither of Masonry's scrolling widgets (`VirtualScroll`, `Portal`)
reacts to a touch drag, only to a wheel-style `PointerEvent::Scroll` —
confirmed by reading (`virtual_scroll.rs:504-523`, `portal.rs:259-267`)
and empirically (a real swipe and a synthetic Android scroll event both
moved nothing on screen). So `transcript-bench.sh`'s own gesture cannot
be performed against a Masonry transcript on Android today, which means
the render-number half of E2's pass condition has no comparison to make
yet — not a bad number, no number obtainable at all. Selection
spanning rows and per-span rich text (bold/italic/inline
code/links inside one paragraph) are also confirmed not possible on
the pinned commit, each for a specific, cited reason. What did work:
block-level rich text (heading size, monospace fences), real
virtualisation of 854 rows, `overwrite_anchor`-based hold-top-edge on
expand (screenshotted), and tap-by-name accessibility. Full writeup,
every citation, and the exact repro commands are in E2's own box below.
- **Done, 2026-09-04: the `Widget::draw`/layout redesign (LAYOUT.md).**
`desired_width`/`desired_height`/`SizeCtx`/`Cache` are gone; every widget
in `iris/src/widget/` implements one `fn draw(&mut self, &mut Painter) ->
Size`. A moved widget (`Scroll`, `Offset`) now costs one
`move_offsets` write resolved by a shared `resolve_move` WGSL function in
both shader stages, independent of how many primitives are in its
subtree — measured at 500 in `iris/src/layout_tests.rs`, which also
covers the unchanged-frame, hit-test-after-move and mask-follows-move
pass conditions as plain unit tests (no GPU or window needed, since
`UiRenderState` touches neither). All four examples render
pixel-identically to before the change. See LAYOUT.md's "Deviations
found during implementation" for five real bugs the design's first draft
did not anticipate — worth reading before touching `Aligned`, `Sized`,
`MaxSize`, `Scroll`, or the move-slot lifecycle again. `GpuTextures::grow_array`
(a second atlas layer opening) has now been exercised too, on `tabs` with
`PAGE` temporarily lowered — see TEXTURES.md's "Exercised, 2026-09-04".
Not done: a pixel-level screenshot check of a `Masked`-wrapped `Scroll`
(no example builds one yet — the numeric check in `layout_tests.rs`
stands in).
- **E1's keyboard gap is Masonry's `as_input_connection` returning `None`
(a TODO), not android-view or `EditorInfo`.** android-view's own demo
implements the `InputConnection` trait over a parley editor and gets
real Gboard suggestions on this emulator — screenshotted 2026-09-04.
android-view's `accesskit_android` adapter also has a reproducible abort
(a client detaching, not attaching, is the trigger) — see E1 below for
both, with the mitigation iris/I4 needs to carry.
- **Resolved, 2026-09-04: iris's binding array does not survive real
Android hardware.** iris's texture pipeline used to ask every device,
unconditionally, for `VK_EXT_descriptor_indexing` ("bindless" binding
arrays), which a real share of Android hardware lacks. It has been
rebuilt per TEXTURES.md's "Recommended shape": the glyph atlas is one
`texture_2d_array` (a layer per page), a standalone image is its own
ordinary `Texture`/`BindGroup`, and `request_device` now asks for no
features and no binding-array limits at all. `rigs/gpu-probe`, rewritten
to match, confirms `request_device` now succeeds on the emulator's
software Vulkan (`EMU_GPU=software`, SwiftShader) — see TEXTURES.md's
"Implemented, 2026-09-04" for the exact command and output, and for what
was verified (rendering, via `run-headless.sh`) versus what was reasoned
through but not separately stress-tested (a real second-atlas-page
grow under load). Nothing here has been run on real Android hardware
yet, only the emulator; the Android Vulkan Profile 2025 sourcing in
"iris's binding array does not survive real Android hardware" below is
what stands in for that until I2 gets a device.
- **I2 — iris on android-view: done 2026-09-05.** The android-view backend
(`iris/src/android/`), the `iris-android-app` cdylib and Gradle shell,
insets, the back gesture, and the full `InputConnection` bridge are all in
and measured working — Gboard's suggestion strip reads real buffer content
through it, the same bar E1 set. **The render gap (nothing drew but the
clear colour) is fixed**: `UiRenderNode::new` seeded the GPU's window
uniform from `WindowUniform::default()` (0, 0) rather than the surface's
real size, so the vertex shader's `/ window.dim` produced `NaN`/`Inf` clip
positions on every primitive, on both Vulkan and GLES — winit's backend
never hit this because winit fires an initial `WindowEvent::Resized` that
corrects it before the first frame, and android-view has no equivalent
event. Fixed by seeding the uniform from `config.width`/`height` at
construction instead of depending on a later resize call. The tabs example
now renders on the emulator on both backends (screenshotted); the
GLES-only `D2`/`D2Array` warning was confirmed a red herring — still
present post-fix, harmless. See I2's own entry below for the full
writeup. **E2** (a transcript in Masonry) is done — see its own box.
- **I3 — `iris::widget::List` built and benchmarked 2026-09-05, ticked in
the box below.** Variable-height rows, virtualised, moved not
relaid-out on scroll, insert-above-anchor and expand-hold both measured
flat across N = 100/1,000/10,000. What is left is wiring it into an
actual transcript screen and comparing against `transcript-bench.sh`'s
Compose baseline on the GPU emulator, which needs a session/scroll model
around it (closer to I5's scope) — see I3's own box for the exact
command once that screen exists. Read `list.rs`'s module doc and
`IRIS.md`'s 2026-09-05 entry before touching it: a widget that fills
whatever region it's offered (a `Rect` background) cannot be measured at
a throwaway region and merely repositioned, a lesson that generalises
beyond this one widget.
- **`client-core` built (2026-09-04)**, item 1 of the recommendation:
`event-model/` (the event types, now shared with `server/`) and
`client-core/` (REST and SSE clients, transcript fold, cache, highlighter,
ANSI parser, 85 ported tests). `CLIENT_CORE.md` maps Kotlin file to Rust
module and lists what is not yet covered. `./run-tests.sh` runs all three
crates.
- **The app itself is untouched.** Everything so far is in `iris/`, in
`rigs/gpu-probe` (a headless wgpu/Vulkan feature probe, pushable to a
device with no APK — see the binding-array section), and in the other
rigs; nothing under `app/` or `server/` has changed.
- **Changed outside this repo**, both in `emulator-tools` and both pushed:
`avd_serial` now validates its cache by asking the device its AVD name
rather than by checking the serial is still attached (a recycled port
silently pointed this checkout at another session's emulator), and
`EMU_GPU=software` was added as an opt-in that keeps a run off the host
GPU and gives the guest a software Vulkan device. The default is
unchanged, because `-gpu host` was measured and the Compose benchmarks
depend on it.
## 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.
## The two constraints that decide it
**1. Android text input.** Every framework built on `winit` inherits
winit's Android backend, and that backend cannot drive the soft keyboard
properly: the IME tracking issues
([#1823](https://github.com/rust-windowing/winit/issues/1823),
[#2766](https://github.com/rust-windowing/winit/issues/2766)) are open,
`ReceivedCharacter` is unimplemented on Android
([#2305](https://github.com/rust-windowing/winit/issues/2305)), and the
`android-activity` groundwork for editor actions only merged in February
2026 ([PR #214](https://github.com/rust-mobile/android-activity/pull/214))
with the winit half still to come. Composition, autocorrect and suggestions
need an `InputConnection` implemented on the Java side, which winit's
`NativeActivity`/`GameActivity` model does not offer. The frameworks that
type on Android today each wrote their own Java glue (Slint, Makepad), and
the one designed to do it the way Android intends is
[`android-view`](https://github.com/rust-mobile/android-view): a Rust
implementation of an Android `View`, with text input through
`InputConnection`, accessibility, touch, callbacks on the UI thread, usable
either as a whole app or embedded beside ordinary Android components. It is
marked WIP. Both Linebender (its Masonry demo lives in that repo) and
Robius/Makepad ([Robrix's release notes](https://github.com/project-robius/robrix/releases)
say Android lacks a "full" keyboard and they are integrating android-view
for it) are converging on it. **That makes android-view the phone-side
foundation whichever widget set sits on top**, and the first thing to
build and measure here.
**2. Rich, selectable text and a virtualised list.** Frameworks group by
their text stack:
- **Parley + Fontique + Vello** (Linebender): rich spans, selection and
editing utilities, IME support driven through `ui-events`, AccessKit text
properties ([Linebender 2026 Q1](https://linebender.org/blog/tmil-25/),
[parley](https://github.com/linebender/parley)). Used by Masonry/Xilem,
and by Blitz. Vello proper needs compute shaders; `vello_hybrid` (CPU
path processing, GPU compositing) is "roughly beta" and runs on GLES too,
and Vello CPU exists as a no-GPU fallback.
- **cosmic-text** (iced, egui optionally): good layout, but the widgets on
top decide selection. iced's `markdown` widget is not selectable
([discourse](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)).
- **Slint's own**: `TextInput` with `read-only` is the selectable-text
trick; there is **no inline rich text at all** (issue
[#1325](https://github.com/slint-ui/slint/issues/1325), markdown request
[#6684](https://github.com/slint-ui/slint/issues/6684) both open). A
markdown transcript with links and code chips cannot be drawn.
- **Makepad's own**: GPU/SDF text, a `Markdown` widget and a virtualised
`PortalList` in `makepad-widgets`.
## Options
### A. Keep Compose, move the logic into a Rust core (uniffi)
A `client-core` crate (events shared with the server, API client, SSE,
transcript fold, cache, markdown model, highlighter, ANSI) exposed to
Kotlin through [uniffi](https://github.com/mozilla/uniffi-rs). Compose keeps
drawing. Desktop would be a second UI (iced or Compose Desktop) over the
same core.
- **For**: the logic and the wire types stop drifting from the server
today, with tests in one language. Incremental and always shippable.
- **Against**: it is not what was asked for. The 13,000 lines of UI stay
Kotlin, the desktop app shares no UI code, and the `:link` Kotlin module
stays. uniffi's Kotlin Multiplatform bindings are a
[community fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings);
the Android-only bindings are Mozilla's and solid.
- **Verdict**: not the destination, but **step one of every other option**
is building this crate, so it costs nothing to keep it as the fallback.
### B. Slint
Rust on Android is officially supported (minSdk 26, `android-activity`
backend, own Java IME glue, safe areas and keyboard insets since 1.15,
Skia renderer needs `clang`). Royalty-free licence requires disclosing
Slint use; GPLv3 otherwise. UI is a separate `.slint` DSL, not Rust.
- **Against**: no rich inline text (see above), so the transcript cannot be
drawn as it is today; the UI language is not Rust, which forfeits the
"compiler catches it" motivation for the half of the code that is UI.
- **Verdict**: rejected on rich text alone.
### C. iced
Elm-style, Rust-only widgets, desktop-first, `winit` + `wgpu`. Has a
`markdown` widget and `rich_text` with links. The maintainer states mobile
is a non-goal ([iced](https://github.com/iced-rs/iced)); a community
Android example exists and its author could not get the soft keyboard
working, patched widgets for touch, and notes no accessibility
([HN thread](https://news.ycombinator.com/item?id=46350641)). Markdown is
not selectable; `scrollable` is not virtualised.
- **Verdict**: a fine desktop toolkit and the one Iris named, but every
phone-side gap (IME, touch, accessibility, selection, virtualisation)
would be ours to build and maintain against a project that does not want
them. Not the shared framework.
### D. egui
Immediate mode, `winit`-based on Android, AccessKit integration,
selectable labels across a `Ui`. Repaints only on input by default, so
battery is not the immediate-mode worry. Android IME is blocked on winit
([discussion](https://github.com/emilk/egui/discussions/2053)); the
workaround is an in-app virtual keyboard, which is exactly the
non-native keyboard to avoid. Variable-height virtualised lists are manual
(`show_rows` assumes uniform heights). Looks like egui, not Material.
- **Verdict**: workable on desktop, wrong on the phone for the same reason
as iced, plus a look that would need a full custom style.
### E. Makepad
GPU-rendered, hybrid retained/immediate, `live_design!` DSL with hot
reload, MIT, 1.0 in 2025 ([makepad](https://github.com/makepad/makepad)).
Ships Android apps today with its own Java glue; Robrix (a Matrix chat
client, the closest analogue to this app) is its reference application on
Android, iOS and desktop. Has `Markdown`, `PortalList` (virtualised),
`TextInput`. Robrix reports the Android keyboard is not "full" and is moving
to android-view for it; the README says non-standard targets "may require
minor fixes".
- **For**: the only option that already ships a chat-shaped app on Android
and desktop from one codebase, with the widgets this app needs.
- **Against**: the DSL is its own language with its own shader-based
styling, so a large part of the UI would not be checked by rustc; the
rendering model (SDF everything) is a different world from Compose's,
and selection across a `Markdown` widget is unverified.
- **Verdict**: **rejected 2026-09-04** — Iris does not want a DSL. Kept
here so its Android keyboard status stays a data point about
android-view, not as an option.
### F. Masonry / Xilem on android-view (Linebender)
Retained widget tree (Masonry) with a reactive view layer (Xilem) that
reads like Compose; Rust all the way down; Vello, Parley, Fontique,
AccessKit, `ui-events`. Widgets include `Prose` (selectable read-only rich
text), `TextArea`, `VirtualScroll`, and this year `Svg`, `Split`,
`CollapsePanel`, a new layout system, and IME through `ui-events`
independent of winit. `masonry_android_view` exists in the android-view
repo and is "not yet generally usable"; Xilem calls itself experimental.
Desktop runs on winit. Vello needs a compute-capable GPU or falls back to
`vello_hybrid`/CPU.
- **For**: the only stack where every hard behaviour above maps onto a
component designed for it: selection and rich text (Parley/Prose),
virtualised variable heights (`VirtualScroll`), native IME
(android-view's `InputConnection`), accessibility (AccessKit, now with an
Android crate), one Rust widget language on both platforms. The team is
the one writing the Android integration everyone else is adopting.
- **Against**: pre-1.0 with API churn each release; a small team; no
Material widget set, so every control's look is ours; some of the pieces
(`masonry_android_view`, `vello_hybrid`) are explicitly unfinished. Being
early means fixing things upstream ourselves, which Iris said is
acceptable.
- **Verdict**: **the option to try first**, because it is the only one
whose gaps are "not finished yet" rather than "not designed for this".
### G. iris — the in-house library, and what "from scratch" means here
[cat16/iris](https://github.com/cat16/iris), read 2026-09-04 from the one
public commit (2026-01-31, "portfolio copy"; ~8,700 lines in `core`,
`macro` and the crate itself). Retained-mode widgets stored outside the
render tree, `wgpu` 28 directly, `winit` 0.30, `cosmic-text` 0.16 (parley
since I1), a
relative-anchor-plus-offset layout with `rest()` and `rel()` lengths, a
postfix builder API (`rect(..).radius(30).on(CursorSense::click(), ..)
.sized(..).align(..)`), events handled where the widget is declared, and
a single-threaded context passed explicitly — all of which reads like this
codebase's own rules. There is text editing (`widget/text/edit.rs`),
images, masks, spans and stacks; the TODO names text resizing as
per-frame slow and scaling as unsolved. It requires **nightly** (fourteen
`#![feature]` gates as vendored, among them `const_trait_impl`,
`unboxed_closures`, `portable_simd`, `associated_type_defaults`; eleven
after I0b and I1 — see those steps for the current list). Desktop only; no
Android surface, no IME, no accessibility tree, no virtualised list, no
rich-text selection.
**That list is the work, and some of it is done.** As of 2026-09-04 it
builds on a pinned nightly, runs on this machine's GPU, has parley and a
glyph atlas, and `iris-core` cross-compiles to Android. What it still
lacks from the list above is the Android surface, the IME bridge, the
accessibility tree and the virtualised list — I2, I3 and I4.
**iris is not a candidate to be tested as it stands. It is the in-house
library** (Iris, 2026-09-04): "essentially a good start to a rewrite from
scratch", to be maintained and extended by the sessions working here.
So the list above of what it lacks is a **work list, not a score**. When
the app needs something iris does not have, the answer is to build it
into iris. The layer iris has is the widget and layout layer; the layers
it needs are the same ones Masonry gets from android-view, Parley and
AccessKit, and there is no reason iris cannot sit on those same
foundations rather than reinvent them — the surface, the keyboard bridge
and the accessibility tree are platform plumbing, not a framework's
identity. The text stack was the first real design decision in that work,
and it is settled: **Parley, with a glyph atlas** (I1, 2026-09-04).
Two things to carry into that work honestly. Nightly is the opposite of
"holds up long term": a build that breaks on a toolchain update, on the
machine Dev Updater builds on, unattended. **Done in I0b** —
`iris/rust-toolchain.toml` pins `nightly-2026-09-03` — and the gate list
lives with I0b and I1, to be retired as they stabilise or are designed
around; it is down from fourteen to eleven. And a one-person framework
carries every gap itself, which is what Iris said she is willing to do.
"From scratch" therefore means iris, not a fourth thing. Masonry stays in
the plan as the **yardstick and the fallback**: building its demo and its
version of the transcript screen first says what a finished stack costs
on this hardware, proves android-view before iris depends on it, and
gives a comparison that is measured rather than remembered.
Not considered further: **GPUI** (Zed) mobile is a community fork that
depends on unpublished crates; **Dioxus/Blitz** is excluded by Iris (its
native renderer is Parley/Vello under HTML semantics, and the earlier
`tdep-survey/app-dioxus` spike parked it on a `vello_hybrid` stroke bug and
shipped the WebView); **Compose Multiplatform Desktop** would give a desktop
app for nothing but in Kotlin, which is the opposite direction.
### Weight and debug builds
Iris remembers the Linebender stack being slow in debug. What is behind
that is the dependency graph — Vello, wgpu, Parley, Fontique, Skrifa —
running unoptimised on the CPU side (path encoding, shaping), not the
widget layer. Xilem's own advice is only `split-debuginfo = "unpacked"` to
keep `target/` small; the fix everyone with this shape of dependency tree
uses is to optimise dependencies while leaving the app crate at `opt-level
= 0`:
[profile.dev.package."*"]
opt-level = 2
**Measured 2026-09-04, and it is not the widget layer.** iris's own
graph (wgpu + winit + cosmic-text at the time) built cold in **43s** with
a 2.1 GB `target/`, and **1m46s** with a 1.5 GB `target/` under the
profile above — so the knob costs build time and saves disk here, and
plain debug was never the problem. Masonry's graph is the one with Vello,
Parley, Fontique and Skrifa in it, and E1 gives the number that matters
for it: **`libmain.so` is 181 MB in debug and 11 MB in release.** That
size is also a correctness issue rather than only a weight one — a debug
build labels its Vulkan objects, and the emulator's driver segfaults in
`SetDebugUtilsObjectNameEXT` when it does. Runtime cost of the profile
knob is still unmeasured; resident memory and the frame cost of an
800-event page want E2. Vello proper needs compute shaders and carries a
large shader set; `vello_hybrid` is lighter and Masonry can now render
through either (or Vello CPU) via its `imaging` abstraction, so "keep it
light" has a knob inside the same stack.
## Recommendation
1. **Build `client-core` now, whatever the framework** (done 2026-09-04, see `CLIENT_CORE.md`). A Rust crate holding
the event model (shared with `server/` as one crate, ending the
`Events.kt` mirror), the API and SSE clients, the transcript fold, the
cache, the markdown block model, the highlighter and the ANSI parser,
with the existing JVM tests ported. It is the part of the app that is
already tested, already logic, and already duplicated on the server.
2. **One foundation, two widget layers.** The platform plumbing is shared
whichever way the decision goes: android-view for the Android surface,
keyboard and accessibility bridge; `wgpu` for the GPU; AccessKit for
names; winit on the desktop. On top of it, **Masonry as the yardstick**
(E1, E2) and **iris as the thing being built** (I0I5), both aimed at
the same transcript screen with the same pass conditions.
3. **Decided, 2026-09-05: iris.** Iris made the call from the host-GPU
comparison in I5's box (iris p50 15.0 ms, Compose 20.0 ms, same
content, same emulator) and from what E1/E2 found Masonry cannot do on
Android today (touch scroll, per-span rich text, cross-row selection,
the keyboard bridge). `DECISIONS.md` has the entry. The paragraph
below is what the decision was to be made from, kept for the record.
**Decide when the transcript screen exists in both**, from the
measurements, and record the decision here with the numbers. If iris
carries the screen within the Compose baseline, it is the app's
framework and Masonry was the calibration. If it does not, the
measurement says which parts of Masonry to adopt underneath it.
**Still not decidable by a render-time number, 2026-09-05 (updated) —
what's missing, named rather than guessed at, and now for a different
reason than before.** E2 found Masonry's own scroll gesture path
absent on Android entirely (its box, "measurable frames") — that has
not changed. I5's Android integration is now built and confirmed
working (real server, real scrolling, real touch-drag pan, tap-by-name
— I5's own box, "Measurements taken"), so the earlier blocker ("no
cdylib/Gradle shell exists for this screen") is gone. What replaced
it: **`dumpsys gfxinfo`, the tool `transcript-bench.sh` and this
recommendation both assumed would give the comparison, cannot see a
`SurfaceView`'s own GPU-drawn frames at all** — it instruments
Android's ordinary View/Skia drawing pipeline, which a `wgpu`-rendered
`SurfaceView` (iris's whole approach) bypasses entirely. Confirmed
0 frames reported across a 24-swipe gesture loop that visibly scrolled
the screen (screenshots differ), and a `dumpsys SurfaceFlinger
--latency` fallback returned no per-frame history either (just the
display's refresh period) on this Android version's BLAST compositor.
The Compose side of the same loop *did* produce a real number under
identical conditions (`EMU_GPU=software`, same emulator, same session):
**8.96% janky frames, 99th percentile 150ms.** So this is now a
one-sided number, not a missing one — the number needed to close item 3
is a render-time report from **iris itself** (the equivalent of the
Compose app's in-app copy-button report `transcript-bench.sh` already
reads), which does not exist yet and is real, scoped follow-on work
(frame timing inside `iris_core::render`, exposed the way `AccessTree`
or `UiRenderState::take_counters` already are) rather than a rerun of
anything above. Until it exists, the decision still rests on the
structural findings both sides *did* produce, now joined by a
functional one: Masonry cannot do cross-row selection or per-span
inline rich text at all today (E2's `grep -rln`, zero hits, cited in
its own box); iris does both (I5's `SpanStyle` and `selection.rs`) and
its touch-scroll now works end-to-end on a real device, not just
programmatically (I3's benchmark plus I5's on-device screenshot
evidence) — three structural points and one functional one in iris's
favour, still with no opposing *or* supporting render-time measurement
on either side.
**Update, 2026-09-05, later the same day: iris now has a render-time
report of its own, and a real number from it, but not yet the clean
comparison item 3 needs.** `iris_core::FrameReport` (new,
`iris/core/src/render/frame_report.rs`) is exactly the follow-on work
named above — a per-frame wall-time ring exposed as two named on-screen
controls, unit tested (6 tests over the ring/percentile math). Driven
for real against a real touch-drag on this checkout's emulator, it
read `frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
worst=98.1ms` — a genuine measurement through iris's own render path,
not inferred. **It is not yet the comparable number**, for a newly
found and separately named reason (I5's own box, "Update, 2026-09-05,
later the same day"): gestures against this checkout's
`EMU_GPU=software` emulator intermittently delivered zero touch input
during this pass — reproducible, but not yet root-caused past one
candidate (the emulator's own software rasterisation measured at ~78%
of a CPU core continuously, a plausible source of input backlog, not
yet confirmed with a sampler running during a failing gesture). So
item 3 still cannot be closed by a clean number, now for a narrower and
more tractable reason than before: the instrumentation exists and
works, and what remains is making the emulator rig deliver touch input
reliably enough to run the comparable loop. **What Iris needs to
weigh, updated**: whether "iris works, Masonry's Android scroll path is
absent entirely, and iris's own frame-timing report is real and
working" is enough to decide without the final clean number, or
whether to wait for the touch-delivery investigation above — still a
product/tradeoff call, left to her (`DECISIONS.md`'s DEFERRED item,
updated with this session's numbers).
4. Then the shell (E3), the desktop window (E4) and the packaging (E5),
which do not depend on the choice.
## Experiments, in order
Each has a pass condition that is a measurement in this clone. The rig
matters: this emulator runs `-gpu host` with **host Vulkan switched off**
(`GPU_HOST_FEATURES` in `emulator-tools`, a gfxstream/Venus gap), so inside
the guest a `wgpu` app gets GLES, not Vulkan; the earlier Dioxus spike also
needed `WGPU_GLES_MINOR_VERSION=1` for compute shaders and found `wgpu`'s
Android backend wants API 26 (a libc symbol). The real phone has Vulkan.
Per the standing rule, a rig limit is something to fix before it is
accepted.
- [x] **E0 — toolchain (done 2026-09-04).** Installed under the
user-owned SDK: **NDK r29 (`29.0.14206865`)**, 2.4 GB at
`~/Android/Sdk/ndk/29.0.14206865`, the newest stable — r30 is still
at rc.3. **cargo-ndk 4.1.2**. Verified by cross-compiling a scratch
`cdylib` to both ABIs: `file` reports "for Android 26, built by NDK
r29 (14206865)" for `aarch64-linux-android` and
`x86_64-linux-android`. Two things to know at the call site.
**cargo-ndk 4's API-level flag is `-P`, not `-p`** — `-p` is now
passed through to cargo as `--package`, so the old
`cargo ndk -t arm64-v8a -p 26` panics with `unknown package: 26`
*and dumps the whole environment to stdout* as a bug report, which is
worth not doing in a log somebody might paste. And the Android
targets were installed for **stable** only; the pinned nightly needs
its own, which `iris/rust-toolchain.toml` now declares.
- [x] **E1 — android-view's Masonry demo on this emulator (2026-09-04).**
It builds, renders on the GPU through Vulkan, exposes its
accessibility tree, and **the phone's own keyboard types into its
editor** — but with **no autocorrect and no suggestions**. Ticked
because everything it was meant to establish is established,
including the one gap; that gap is now E2's problem and I2's.
*Build.* `~/src/android-view` at `bec6c62`, x86_64 rather than the
README's arm64 because that is what this emulator is:
`cargo ndk -t x86_64 -P 26 -o masonry-app/src/main/jniLibs/ build -p
android-view-masonry-demo --release`, then
`./gradlew :masonry-app:assembleDebug`. **`libmain.so` is 181 MB in
debug and 11 MB in release** — the loudest single number about
Vello's dependency graph, and the reason the release build matters
for more than speed.
*Renderer.* wgpu takes **Vulkan**, and the emulator log confirms it
from the other side: `Created VkDevice ... for application:'wgpu'`.
Two things were needed. The emulator must be given Vulkan at all —
`-feature Vulkan` with `VK_DRIVER_FILES` pointing at the SDK's
`vk_swiftshader_icd.json`, **plus `-no-snapshot-load`**, which is the
piece this file had flagged as untested: without a cold boot the
guest keeps the snapshot's old GPU config and `cmd gpu vkjson`
reports zero devices however the host is configured. And the native
library must be **release**: a debug build calls
`SetDebugUtilsObjectNameEXT` to label its image views, and the
emulator's own guest driver (`vulkan.ranchu.so`) segfaults inside it.
On GLES, with no Vulkan available, it instead fails
`Surface::configure` with "Invalid surface" — untriaged, since the
Vulkan path works and Vello wants compute shaders anyway.
*Accessibility works*, which E2's condition 6 and every bench script
depend on. `ui-trace` reads Masonry's AccessKit tree: "Add task"
arrives as a named `Button`, the editor as an `EditText` node. So
tap-by-name works against a Masonry screen for any control carrying
a name; the demo's editor carries none, which is the demo's omission
rather than the framework's.
*The keyboard: real input yes, suggestions no.* Tapping the editor
opens the actual soft keyboard (`mInputShown=true`, Gboard), and
tapping its keys types into Masonry — "teh" typed key by key, with a
caret. What does **not** appear is Gboard's suggestion strip. The
control is what makes that a finding rather than an impression: the
**same three key taps in the Settings app's search field, on the same
device in the same session, produce "teh | the | yeh"**. So the strip
works here and android-view's editor is not asking for it — most
likely the `EditorInfo` its `InputConnection` reports. That matches
Robrix's report that the Android keyboard is not yet "full", and it
is the single most important thing to fix or fund upstream, because
composition, autocorrect and suggestions are exactly what the
composer in this app needs and exactly what `winit` cannot do at all.
**Cause found 2026-09-04, and it is Masonry's, not android-view's.**
`~/src/android-view/masonry/src/lib.rs:531` is
fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> {
// TODO
None
}
so the Masonry demo has **no `InputConnection` at all**; `RustView`
returns null from `onCreateInputConnection` and the IME falls back to
dispatching raw key events, which is exactly the behaviour observed —
keys arrive, composition does not exist, so there is nothing for
Gboard to suggest against. It is not a wrong `EditorInfo`, and the
guess above that it was is withdrawn.
android-view's **own** demo (`demo/src/lib.rs`, packaged by `app/`)
implements the whole trait against a parley editor and asks for
`INPUT_TYPE_CLASS_TEXT | CAP_SENTENCES | AUTO_CORRECT | MULTI_LINE`
with `IME_FLAG_NO_FULLSCREEN | NO_EXTRACT_UI | NO_ENTER_ACTION`
(`demo/src/lib.rs:588`). So the capability is present in the layer
iris would sit on, and the 30-odd method `InputConnection` trait in
`src/ime.rs` — `set_composing_text`, `set_composing_region`,
`finish_composing_text`, `text_before_cursor`, `cursor_caps_mode`,
`request_cursor_updates`, and `InputMethodManager::update_selection`
to push the selection back — is the full surface an IME needs.
**This changes what I2 costs**: the IME bridge is a trait to
implement over iris's parley editor, not a gap to fund upstream. It
also means E2 inherits Masonry's TODO, so a Masonry transcript will
have the same dead composer until somebody fills that in.
**Measured on the emulator, same session, same device.** Built
android-view's own demo — `cargo ndk -t x86_64 -P 26 -o
app/src/main/jniLibs/ build -p android-view-demo --release`, then
`./gradlew :app:assembleDebug`, installed with `ANDROID_SERIAL=$(emu
serial)` — and tapped into its editor. `dumpsys input_method` reports
`mInputShown=true` with `mServedView=…viewdemo.DemoView`, and the
screenshot shows **Gboard's suggestion strip populated with "dolor |
Dolores | door"**: the caret had landed inside the word *dolor* in
the demo's lorem ipsum, and Gboard read that word out of the Rust
editor through `text_before_cursor`. So on this emulator, through
android-view, a parley editor gets a real IME with real suggestions
drawn from its own buffer. That is the bar E1 could not reach and the
bar I2 is written against, and it is now known to be reachable.
*One crash seen once — reproduced and diagnosed 2026-09-04.* With an
accessibility client attached the app aborted, stack:
`android_view::view::do_frame` → `CallbackCtx::finish` →
`accesskit_android::event::QueuedEvents::raise` →
`send_completed_event` → `unwrap()` on `Err(JavaException)`.
android-view builds `panic = "abort"`, so a JNI call that throws
takes the process. Two later `ui-trace record` runs left the app
alive, so the trigger looked narrower than "a client is attached".
**It is the opposite of "a client is attached": it is a client
having *detached*.** `accesskit_android`'s `State` enum
(`adapter.rs:161` in 0.4.0, `:192` in 0.8.0) is
`Inactive | Placeholder | Active`, and **nothing ever moves it back
to `Inactive`**. A client — `ui-trace`, which is uiautomator — calls
into the node provider once, `get_or_init_tree` promotes the adapter
to `Active`, and it stays there for the life of the process. Every
later change then returns `Some(QueuedEvents)`, `raise` calls
`ViewParent.requestSendAccessibilityEvent`, and that reaches
`AccessibilityManager.sendAccessibilityEvent`, which on the main
looper **throws `IllegalStateException("Accessibility off. Did you
forget to check that?")` when accessibility is disabled**. jni-rs
returns `Err(JavaException)`, `send_completed_event` unwraps it, and
`panic = "abort"` ends the process.
*The controlled run*, one process (pid 4085), `settings get secure
accessibility_enabled` = 0 throughout:
- tapped the editor and typed three keys with `adb shell input tap`,
no client ever attached — **alive**;
- one `ui-trace record -d 800` with no gesture at all, then two
seconds' wait — **still alive** (the queue was raised while the
client was still there);
- the very next three keystrokes, same process — **aborted**, same
stack.
So the failure is not the recording; it is the **first thing that
changes the accessibility tree after a recording ends**. That makes
it a standing hazard for this project rather than an oddity:
`transcript-bench.sh`, `stream-bench.sh` and `bench-lib.sh`'s
tap-by-name all attach and detach uiautomator, so on a Rust app the
typing or scrolling *after* a bench run is what dies, several
seconds away from anything that looks like a cause.
**Still present at head**: 0.8.0 is the newest `accesskit_android`
(the demo resolves 0.4.0) and both the unconditional `unwrap` in
`send_completed_event` and the one-way `State` are unchanged there,
so upgrading is not the fix. **Our mitigation for I2/I4 is a gate we
own**: ask `AccessibilityManager.isEnabled()` before calling
`raise`, and drop the events when it says no. Worth reporting
upstream as well — the honest fix is for `raise` to clear a pending
exception rather than unwrap it, since a view can be detached or
accessibility switched off between queueing and raising no matter
who is calling.
*A rig trap that cost a wrong conclusion.* Several bounded runs were
given `sleep N; emu down` watchdogs, and one armed for an earlier
experiment fired in the middle of a later one — the app vanished, adb
hung, and it read exactly like the Vulkan path crashing. It was not.
A watchdog must be scoped to the process it guards (`kill $pid`, with
the pid captured at launch) rather than to whatever AVD is running
when it wakes, and only one should be armed at a time.
- [x] **E2 — a transcript in Masonry (2026-09-05).** Built and run on this
emulator. It found the thing it was measuring for: a framework-wide
gap that blocks the bench comparison itself, plus a full accounting
of the seven behaviours. Ticked on E1's own precedent -- "everything
it was meant to establish is established, including the one gap."
*Where it lives.* `~/src/android-view/e2-transcript` (new workspace
member, `crate-type = ["cdylib"]`, `lib.name = "main"`), packaged by
a new Gradle module `~/src/android-view/e2-app` copied from
`masonry-app` (`E2View`/`E2Activity`, package
`org.linebender.android.e2transcript`). Neither is committed to
`ai-app-2` or pushed anywhere -- same as E1, this is a local
experiment against the `xilem` commit
`e14ba3a5f9461b403cb30d95826187fba7f6924b` and the `android-view`
commit `bec6c62a96cef8239b0fd7fedeef9b184d02e3a1`, reproducible from
the commands below rather than from a remote.
*Build.* Depends on `client-core`/`event-model` from this checkout by
path (`../../../repos/ai-app-2/client-core`) -- real code, not a
reimplementation: `ApiClient`/`UreqTransport` for the HTTP fetch,
`fold_event`/`group_tool_runs` for the transcript fold, exactly what
the app itself would use. The sandbox CA and a session's URL/token
are baked in at build time via `env!()`/`include_bytes!()`, the same
pattern the real APK uses to pin its CA (AGENTS.md), since this is a
throwaway screen with no enrollment flow:
cd app && ./ui-sandbox.sh start # prints the port and token
sid=$(./ui-sandbox.sh spawn e2test)
./ui-sandbox.sh send "$sid" @/tmp/big.md # markdown content
./ui-sandbox.sh send "$sid" "/tools 3" # a grouped tool run
cd ~/src/android-view
E2_SANDBOX_URL=https://10.0.2.2:<port> \
E2_SANDBOX_TOKEN=<token> \
E2_SANDBOX_SESSION=<sid> \
E2_CA_PEM_PATH=$HOME/.config/ai-app/certs/ca.pem \
cargo ndk -t x86_64 -P 26 -o e2-app/src/main/jniLibs/ \
build -p e2-transcript --release
ANDROID_HOME=~/Android/Sdk ./gradlew :e2-app:assembleDebug
**`libmain.so` is 13.5 MB release** (E1's masonry-demo was 11 MB;
the difference is `client-core`'s `ureq`/`rustls` stack, which E1's
demo does not link). Release native lib, debug Gradle variant --
the combination E1 found necessary (a debug build's
`SetDebugUtilsObjectNameEXT` segfaults this emulator's Vulkan
driver).
*Emulator.* This checkout's own AVD (`ai-app-2`, not `ai-app`, which
another session already had up), booted with Vulkan the way E1
established: `GPU_HOST_FEATURES="-feature Vulkan"
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json
emu up`. `adb shell cmd gpu vkjson` confirmed a device before
anything was installed. Torn down with `emu down` at the end of this
session (see "Where things stand" below for the exact state left).
*What it does.* `fetch_rows()` (`e2-transcript/src/lib.rs`) makes one
blocking `fetch_transcript_page(session, None, 800, false)` call
before the widget tree exists, folds every line through
`client-core`, and groups tool runs -- 854 real events from a mixed
sandbox session (markdown paragraphs/headings/fences plus a
three-call tool run from the echo driver's `/tools 3`). Each
`TranscriptRow` becomes one `VirtualScroll<dyn Widget>` child,
built lazily from `VirtualScrollAction` the way
`masonry_winit/examples/virtual_fizzbuzz.rs` does it. **This is a
deliberate scope cut from "page 800 events" as live paging**: all
854 rows' content is fetched once, and what `VirtualScroll` pages is
*widget construction*, not a second round of network calls per
scroll -- wiring a background-thread fetch woken across the JNI
boundary (the way I2's `ssh.rs` attach-and-call works) is real work
this experiment did not need to answer its question. `markdown.rs`
is a `pulldown-cmark` event-stream walk into a small `Block` enum
(`Text`/`Heading`/`Code`), with its own module doc explaining the
one real ceiling it hit (below).
*Verification.* `cargo fmt -p e2-transcript -- --check` clean.
`cargo ndk -t x86_64 -P 26 clippy -p e2-transcript --all-targets`:
**zero warnings in this crate** (the only clippy output at all is
from `android-view` itself, a vendored dependency this experiment
does not own). `cargo ndk -t x86_64 -P 26 test -p e2-transcript
--lib` (run against the emulator, since the crate is
`cfg`-unconditionally Android): 1 test, `markdown::parse`'s block
split, passing. No larger test surface exists to port -- this is a
throwaway screen, not a library, matching AGENTS.md's "match the
codebase's testing posture."
*Screenshots* (all `/tmp`, not committed -- see the standing rule
against transcripts leaving this repo, which applies equally to a
screenshot of one): `e2-screenshot2.png` first real content;
`e2-expand.png` a tool row expanded with its top edge held;
`e2-markdown.png` a heading/bold/italic/inline-code/link/fenced-code
message (the "You said: ## A Heading" line is the sandbox's echo
driver prefixing the literal input text before the `##`, which
keeps `pulldown-cmark` from recognising it as a heading -- a fixture
artifact, not a finding about Masonry).
**The seven behaviours, each shown or given a sourced reason:**
1. **One selectable body of text spanning rows -- not possible, and
it is a real ceiling, not an oversight.** `Prose` wraps exactly
one `TextArea<false>`, which wraps exactly one
`parley::PlainEditor` (`masonry/src/widgets/prose.rs`: "Note that
copying is not yet implemented"). Selection lives entirely inside
that one editor: `TextArea::on_pointer_event`
(`masonry/src/widgets/text_area.rs:414-459` in the pinned `xilem`
commit) captures the pointer on `Down`
(`ctx.capture_pointer()`) and drives `self.editor`'s own
`extend_selection_to_point` on `Move` -- there is no code path,
in `masonry_core` or `masonry`, that extends a selection into a
second widget's editor. A drag that starts in one row's `Prose`
and continues into the next is still that first row's own
`PlainEditor` being asked for a point outside its bounds; it
cannot reach the second row's text. Confirmed by reading, not
guessed at: there is no `SelectionContainer`-shaped type
anywhere in `masonry`, `masonry_core` or `xilem` (checked with
`grep -rln "SelectionContainer\|cross.widget.*selection"`, zero
hits).
2. **Rich inline text -- block-level yes, inline no, and both for
the same reason.** `TextArea::edit_styles()` returns one
`&mut StyleSet<T>` for the whole editor
(`masonry_core/src/core/text.rs:29-32` defines `StyleSet` as
`parley::StyleSet<BrushIndex>`, applied editor-wide); the type's
own comments say why nothing finer exists yet:
`// TODO: RichTextInput 👀` and
`// TODO: Support for links - https://github.com/linebender/xilem/issues/360`
at `masonry/src/widgets/text_area.rs:43-44`. So bold, italic,
inline code and a link *inside one paragraph* cannot each carry
their own style without leaving `TextArea` for a hand-rolled
`parley::Layout` (which loses selection, the caret and copy,
since those live inside `PlainEditor` specifically). What **is**
real: each markdown block is its own `Prose`, so a heading is a
bigger font and a fenced code block is monospace, screenshotted
in `e2-markdown.png` -- block-level style works because it is
block-level *widgets*, not a rich-text API. Tables and per-token
syntax colour inside a fence hit the identical ceiling (both are
per-range styling) and were not attempted for the same reason.
`markdown.rs`'s degraded rendering (backticks kept literally,
`[text](url)` shown as `text (url)`) is the honest fallback,
documented at the point it is produced.
3. **Bottom-anchored virtualised list, paged, hold-top-edge on
expand -- mostly shown, with one real gap in the anchor API.**
`VirtualScroll<dyn Widget>` holds all 854 folded rows;
`overwrite_anchor` before swapping a tool row's widget for its
expanded/collapsed version is exactly the primitive
`holdTopEdge` needs, and it worked: `e2-expand.png` shows the
row growing downward from the same top edge it had collapsed,
no jump. Virtualisation is real (`ui-trace elements` only ever
lists the rows currently on screen, never all 854). **What did
not come free: hugging the bottom of the screen.**
`VirtualScroll::new`'s doc says "the item at `initial_anchor`
will have its top aligned with the top of the scroll area" --
so anchoring on the last row puts that row's top at the
viewport's *top*, with empty space below it, not at the
viewport's bottom the way a chat transcript wants (visible in
`e2-screenshot2.png`). The complete public `WidgetMut` surface of
`VirtualScroll` is `new`, `with_valid_range`,
`will_handle_action`, `add_child`, `remove_child`, `child_mut`,
`set_valid_range`, `overwrite_anchor`
(`masonry/src/widgets/virtual_scroll.rs:257-428`) -- no
scroll-offset setter and no reverse/bottom-up layout mode exist
to ask for the other behaviour. Backward paging beyond the
initial 800 was not exercised, per the scope cut above.
4. **The soft keyboard -- inherited gap, not re-investigated.** E2's
screen has no `TextInput`, only read-only `Prose`/`Button`, so it
does not hit `masonry/src/lib.rs:531`'s `as_input_connection`
returning `None` directly -- but it would the moment a composer
is added, per E1's finding. Nothing new to add here.
5. **Platform integration -- out of scope by design.** Foreground
service, notifications, share sheet, deep link, Keystore,
camera, back gesture, edge-to-edge, local-network permission are
E3's list in RUST.md's own experiment order, not E2's.
6. **Accessibility names -- shown, and the bench-script dependency
actually exercised.** `ui-trace record --do "tap '> 3 tool
calls'"` found the button by its label and pressed it (that tap
is what produced `e2-expand.png`); `Prose` rows surface their
text as their accessible name too (`ui-trace elements` lists
"You said: One more short reply..." etc. as named nodes). Tap by
name, the rule this whole project's bench scripts depend on,
works against this screen.
7. **Measurable frames -- blocked, and this is the finding E2 was
really testing for.** Two separate problems, one of them fatal
to the render-numbers half of this box's own pass condition.
First, Masonry has no render-report/per-widget-cost
instrumentation the way Compose's `DebugStats` gives this
project -- building one was out of scope here. Second, and this
is the one that matters: **neither of Masonry's two scrolling
widgets responds to a touch drag at all.**
`VirtualScroll::on_pointer_event`
(`masonry/src/widgets/virtual_scroll.rs:504-523`) and
`Portal::on_pointer_event`
(`masonry/src/widgets/portal.rs:259-267`) both match only
`PointerEvent::Scroll` (wheel/trackpad deltas) and do nothing
with `PointerEvent::Down`/`Move`/`Up` -- there is no drag-to-scroll
gesture logic anywhere in the widget set. `android-view`'s own
Java bridge keeps the two paths separate at the source:
`RustView.java`'s `onTouchEvent` forwards raw touch straight to
Rust, and only `onGenericMotionEvent` (mouse/trackpad, not
touch) reaches the `ACTION_SCROLL` branch that becomes
`PointerEvent::Scroll`
(`android-view/src/events.rs:530`). Confirmed empirically, not
just by reading: a real swipe (`ui-trace`'s `swipe 540 1600 540
400 300`, twice) moved nothing (`e2-scroll.png` is pixel-identical
to the screen before it), and a synthetic Android wheel event
(`adb shell input scroll 540 1200 --axis VSCROLL,-5`) also moved
nothing. **This means `transcript-bench.sh`'s own gesture --
a finger swipe -- cannot scroll a Masonry transcript on Android
today, at all, on this framework commit.** So the "render
numbers land within the Compose baseline" half of this box's
pass condition cannot be attempted, let alone met: there is no
way to perform the scroll the comparison asks for. This is not
a performance shortfall to close by writing faster code: it is
an absent input path upstream. The fix is a drag-to-scroll
gesture in `on_pointer_event` (the same place `TextArea`'s own
caret-drag logic already lives, so the pattern -- capture on
`Down`, accumulate delta on `Move`, release on `Up` -- exists
in this codebase already, just not wired into either scrolling
widget), and it belongs upstream in `xilem` rather than in this
project.
**Net for RUST.md's recommendation.** Item 3 ("decide when the
transcript screen exists in both, from the measurements") cannot be
decided by a render-number comparison yet, because the comparison's
own gesture does not work on Masonry on Android. What *can* be
compared today is structural: iris already has a working scroll
gesture and a working touch model (I2, 2026-09-05) that Masonry's
upstream commit does not yet have for this exact case. That is a
point in iris's favour that a frame-time number would not have
shown any more clearly.
- [x] **E3 — the shell (2026-09-05).** Both pass-condition proofs held on
the emulator: a notification arrived while the app was closed, and a
shared text share landed as a real message in a session's transcript.
Committed to this repo (unlike E1/E2's external, uncommitted trees),
since this is lightweight glue rather than a multi-gigabyte native
build.
*Where it lives.* `android-shell/` (new crate, `client-core` as its
only real dependency) is the JNI bridge; `app/shellApp/` is a **new
Gradle module**, not a rewrite of `app/androidApp` in place --
that module is ~13,000 lines of working Compose UI this experiment
does not touch or risk, and the two install side by side on one
development device. `app/shellApp`'s manifest, channel names,
notification wording and share intent-filter are copied from
`androidApp`'s (`Notifications.kt`, `Share.kt`, the manifest) per
AGENTS.md's "reuse rather than re-derive" -- see each file's own doc
comment for exactly what was carried over. Two deliberate
differences, both practical rather than behavioural: application id
`com.example.aiapp.shell` and deep-link scheme `aiappshell` (not
`aiapp`), so this experiment's install cannot collide with the real
app's enrollment or Keystore alias on the same phone -- see
`android-shell/src/settings.rs`'s `SCHEME` doc.
*The Java floor, and one line more than planned.* Two classes,
matching "How much Java is unavoidable" almost exactly:
`MainActivity.java` (`onCreate`/`onNewIntent` forward to
`nativeHandleIntent`) and `NotificationService.java`
(`onStartCommand`/`onDestroy`/a `sync()` companion, three natives).
Both ~30 lines including the license-free boilerplate Java itself
demands (imports, `System.loadLibrary`). **One addition the analysis
did not anticipate**: `MainActivity.toast(Context, String)`, a
plain (non-native) static method Rust *calls* rather than
implements, because posting a `Toast` from `share.rs`'s background
thread needs a hop back to the main looper
(`new Handler(Looper.getMainLooper()).post(...)`), and JNI can call
an existing Java method on any thread but cannot construct a Java
`Runnable` to hand to `Handler.post`/`runOnUiThread` without a
reflection proxy uglier than three lines of Java. Recorded here
because "the floor is two classes of ten lines" undersold this by
exactly one small, call-only method -- the pattern (Rust calls
Java, never Rust implements a Java interface) is worth keeping the
next time this floor is estimated.
*What client-core gained.* `notifications.rs`: `SessionNotification`,
`NotificationKind` (mirroring `server/src/session/mod.rs`'s wire
shape field-for-field) and `follow_notifications`, the SSE parse
over `/notifications` built on the same `sse::SseReader` and
`Transport` trait `event_stream.rs` already uses. `attention_line`
is ported verbatim from `Notifications.kt`. 3 new tests (88 total in
the crate); `android-shell` itself has none, since every function in
it needs a live `Env` and there is no pure logic left to test in
isolation once client-core owns the parsing -- matches E2's
precedent ("a throwaway screen, not a library").
*Scope cuts, each recorded at its own point in the code rather than
only here:*
- **Text-only share.** `Intent.EXTRA_TEXT` becomes a session message;
a shared file/photo URI is not uploaded, because `client-core`'s
`ApiClient` has no `/sessions/{id}/attachments` route yet either
(`CLIENT_CORE.md`'s own "not covered" list) -- porting
`Attachments.kt`'s `ContentResolver` reads and bitmap downscaling
is real work belonging to whichever caller needs it next, not a
detour inside this box.
- **No session picker.** With no screen drawn yet (E4's job), a
share attaches to whichever session has the latest
`last_activity` -- documented as a placeholder in `share.rs`,
not a designed behaviour.
- **No banner/on-screen suppression.** `notify::show` skips
`Notifications.kt`'s "nothing if this session is on screen" /
"hand to the app as a banner" branches entirely: both read
process-wide state that only means something once a screen
exists to register against it, so every notification here takes
the platform-drawer branch -- which is also exactly what the pass
condition asks for. Revisit once E4 draws something.
- **Keystore is not reimplemented in Rust.** `settings.rs` calls
`wg-app-link`'s existing `ServerStore`/`ServerSettings` Kotlin
classes over JNI rather than re-deriving the AES-GCM sealing:
that code is shared with Dev Updater, already tested, and tied to
a Keystore alias an existing enrolled phone depends on. This does
mean `kotlinc` stays in the toolchain regardless of what E5 does
with `javac`/`d8` for this module's own two classes -- a
correction to "Can the APK be built without Gradle?"'s assumption
that dropping Kotlin drops `kotlinc` outright; it drops it for
*this app's own code*, not for a shared submodule pulled in as a
dependency.
*`jni` 0.22, not the older API most examples assume.* This is a
real API split (`Env` for real work, `EnvUnowned` as the FFI-safe
type a native fn receives, joined by `EnvUnowned::with_env`), and
the `native_method!` macro (used for all four natives here, via
`const _: NativeMethod = native_method! { ... }`) generates both the
mangled `Java_...` export and the panic/error-handling wrapper from
one Rust function signature -- chosen over hand-written
`#[unsafe(no_mangle)] extern "system" fn Java_com_..._method` because
a hand-typed export name and a hand-typed JNI signature string
routinely drift from the Java they claim to match, silently (see
the next two findings, both of which were exactly that drift).
`error_policy = LogErrorAndDefault` reports a failure to logcat
rather than throwing it back into Java as an exception that would
crash the app over something recoverable -- matching
`Notifications.kt`'s own "log, don't crash" posture, but it is a
no-op without a logger backend (`android_logger`, Android-only
dependency, `lib.rs`'s `ensure_logger`) installed; the class of bug
this exists to report was found once with no logger and read as
nothing having gone wrong at all.
**Three real findings, each cost a failed run before being
diagnosed, each written where the fix lives so a reader who touches
that file again does not lose an afternoon to it:**
1. **A generic `JObject` parameter type silently exports the wrong
JNI signature.** `native_method!`'s shorthand
`fn native_sync(context: JObject) -> ()` encodes the export as
`(Ljava/lang/Object;)V`, because it has no way to know the
intended Java type is `android.content.Context` from a bare
`JObject`. The real Java method is declared
`(Landroid/content/Context;)V`; the two mangled names never
resolve to each other, and the failure is
`UnsatisfiedLinkError: No implementation found`, thrown the
moment Java calls it -- not a build error on either side. Fixed
by spelling each parameter as its actual Java type in the macro
invocation (`context: android.content.Context`, `activity:
android.app.Activity`, ...), which the macro accepts directly
per its "Java Object Types" syntax, while the Rust implementation
function keeps the parameter as plain `JObject` (the "Built-in
Types" fallback for a Java class with no dedicated Rust
wrapper). `lib.rs`'s comment beside the first `native_method!`
call is the citation.
2. **A class looked up by name from this crate's own background
thread fails, and only for app classes.** `android-shell`'s
follow-loop and share threads are Rust-spawned and attached via
`JavaVM::attach_current_thread`, which the platform never handed
an app `ClassLoader` -- so `FindClass`'s default fallback (used
internally by `find_class`/`new_object`/`call_static_method`/
`get_static_field`, anything that resolves a class *by name*
rather than from an object it already holds) only reaches the
bootstrap loader's framework classes. `androidx.core.app.
NotificationManagerCompat`, packaged inside this app's own APK,
is invisible from there: `Error::NoClassDefFound`, logged by
`notify::show`'s `LogErrorAndDefault` as "failed to resolve Java
class ... (class not found or linkage error)" -- which on a real
device is indistinguishable from "the notification silently
never arrives," since the *ongoing* foreground notification
(built on the main thread, before this thread exists) posts
fine regardless, so nothing else looks wrong. Fixed in
`jcall.rs`: `remember_class_loader` caches the app's own
`ClassLoader` (`context.getClass().getClassLoader()`) the first
time any entry point with a `Context` runs, and every
class-by-name lookup goes through `LoaderContext::Loader`
explicitly rather than the thread-dependent default -- correct
on the main thread and this crate's background threads alike.
`jcall.rs`'s module doc has the full account.
3. **`onStartCommand` spawning a thread unconditionally opens a
second connection, and `Notifications.kt` has the same bug.**
Enrolling calls `sync()` twice in one launch (once
unconditionally in `MainActivity.onCreate`, again inside
`handle_enrollment` after saving the token), each of which starts
the service, and Android runs `onStartCommand` once per start
request -- so the follow-loop thread was spawned twice, caught on
`adb logcat` as two `jni::vm::java_vm: Attached thread
ai-app-notifications` lines for one enrollment. Kotlin's
`onStartCommand` has the identical shape (`thread(isDaemon =
true) { follow(settings) }`, no guard), so this is a latent bug
in the reference implementation this port found by testing
rather than something E3 introduced -- worth carrying the same
guard back to `Notifications.kt` separately, not done here.
Fixed in `notify.rs` with a `RUNNING` `AtomicBool`, `swap`ped
true before spawning and reset in `on_destroy`; see its doc
comment for the accepted race this shares with the pre-existing
`STOPPING` gap below.
**Known gap, not fixed, written where it will be found.**
`notify.rs`'s `STOPPING` flag (checked between reconnects) cannot
interrupt a `ureq` read already blocked inside one connection --
unlike `HttpURLConnection.disconnect()`, `client_core::Transport`
exposes no cancellation handle. `/notifications` is idle between
events (a keep-alive), so in practice a stop is a bounded wait
rather than a hang; closing this for real means adding a
cancellation point to the `Transport` trait itself, a decision
affecting every caller, not an `android-shell`-only fix.
*Verification, exact commands.* `cargo fmt -- --check`,
`cargo clippy --all-targets` (zero warnings) and `cargo build`
clean for both `client-core` and `android-shell` on the host
target; `cargo ndk -t x86_64 -P 26 clippy --all-targets` clean for
`android-shell` on the Android target too (the `android_logger`
dependency is Android-only, so this is the only way to compile-check
it). `./run-tests.sh` from the repo root: 127 `server` tests, 88
`client-core` tests (85 + the 3 new to `notifications.rs`), all
passing -- the port added no regression to what already worked.
`./gradlew :shellApp:lintDebug`: `No issues found` (the report at
`app/shellApp/build/reports/lint-results-debug.txt`).
*The two pass-condition proofs*, both on this checkout's own AVD
(`ai-app-2`, GPU host per the default, torn down with `emu down`
when this session finished) against `app/ui-sandbox.sh`:
- **Notification with the app closed.** Enrolled via
`adb shell "am start -a android.intent.action.VIEW -d
'aiappshell://enroll?host=10.0.2.2&port=<sandbox port>&token=<token>'"`
(per the sandbox's own banner, substituting the scheme), granted
`POST_NOTIFICATIONS`, pressed home, then
`./ui-sandbox.sh spawn e3notif2` and
`./ui-sandbox.sh send <sid> "/question Should I proceed with the deploy?"`.
`adb shell dumpsys notification --noredact` shows a
`channel=sessions` record, `android.title=e3notif2`,
`android.text=Waiting for you` (matching `attention_line` and the
session's own title, exactly what `Notifications.kt` would have
shown) -- posted while the app held no visible activity. Tapping
it (`ui-trace record --do "tap 'e3notif2'"`, found in the
expanded shade after `adb shell cmd statusbar
expand-notifications`) launched
`com.example.aiapp.shell/.MainActivity` with
`dat=aiappshell://session/...`, confirmed in `adb logcat`'s
`ActivityTaskManager: START` line -- the `PendingIntent` names
the right session.
- **A share lands in a session.** With the app enrolled and a
session already active,
`adb shell "am start -a android.intent.action.SEND -t text/plain
--es android.intent.extra.TEXT 'Please check the deploy logs for
errors.' -n com.example.aiapp.shell/.MainActivity"` (the classic
`adb shell` quoting trap from `this-machine-android` applies here
too: the whole `am start` invocation has to be one single-quoted
string handed to the *remote* shell, or the extra's spaces get
re-split away). `./ui-sandbox.sh api
'/sessions/<sid>/transcript?limit=20'` shows
`{"type":"userMessage","text":"Please check the deploy logs for
errors."}` followed by the echo driver's reply -- the share
reached the most-recently-active session as a real message, not
a mock.
- [x] **E4 — the same screen on the desktop (2026-09-05).** A new
`iris/desktop-app` crate (added to the `iris` workspace's members, not
excluded the way `android-app` is -- nothing here needs the NDK):
a real winit window showing a session list (`iris::widget::Span`,
rebuilt on selection) beside `transcript-ui`'s screen
(`transcript_ui::build_tree`, new this box -- see IRIS.md's
2026-09-05 entry), talking to a real `ai-server` through
`client-core`'s `ApiClient`/`UreqTransport`/`follow_session_events`.
Enrolment is `client_core::config::EnrolledServer::parse_link`
against the same `aiapp://enroll?host=H&port=P&token=T` link a phone
scans, pasted via `--link` and persisted at
`$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` (0600 --
`iris/desktop-app/src/config.rs`); the pinned CA is a `--ca PATH`
argument, never baked in (DECISIONS.md, 2026-09-05).
*Both pass-condition proofs held, against `app/ui-sandbox.sh`'s real
server.* (1) The list showed the sandbox's spawned session
("Demo session", its live status); selecting it loaded the real
transcript and the composer's `Submit` posted a message whose reply
streamed in live over SSE, both proved by two `run-headless.sh`
screenshots taken seconds apart around a real `./ui-sandbox.sh send`
-- the second showed the new turn appended under the first with
nothing duplicated or lost. (2) Screenshotted headless:
`/tmp/iris_e4_desktop.png` (1920x1200, 15.9 KB, the real first-run
state -- list populated, "Select a session." on the right, nothing
selected yet). `run-headless.sh` gained a `--bin` flag for this
(`cargo build --bin NAME` + `target/debug/NAME` instead of the
`--example` path, since `desktop-app` is a real binary a person
runs, not a demo) and `$RUN_HEADLESS_ARGS`, word-split into the
launched binary's own argv (a real CLI's flags, which no example
needed a way to pass before). Exact commands, from `iris/`:
TOKEN=$(cat "${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/sandbox-token")
LINK="aiapp://enroll?host=127.0.0.1&port=<PORT>&token=$(python3 -c \
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$TOKEN")"
CA="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem"
RUN_HEADLESS_ARGS="--ca $CA --link $LINK" \
./run-headless.sh desktop-app --bin --shot /tmp/iris_e4_desktop.png -- -p desktop-app
**A real bug this screenshot found, not a synthetic one**: the first
attempt resumed the live SSE stream from
`items.iter().map(TranscriptItem::seq).max()` -- the *folded* item's
seq, which for a still-open `AssistantMsg` is the seq of its
*first* delta by design (`fold_event`'s own doc comment: "a row
whose identity changed with every delta would be a new row every
frame"). Resuming from there re-delivered every delta already
folded into that message, and the screenshot showed the assistant's
reply with its own tail duplicated ("You said: ... testsaid: ...
test"). Fixed by computing the resume cursor from the raw wire
`seq` of the last fetched line (`app.rs`'s `raw_seq`) instead of
from any folded item -- regression test
`the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq` in
`iris/desktop-app/src/app.rs`. Exactly the class of bug CODE_RULES
warns about under "a fix tried only on what it was meant to fix":
the bare REST fetch (no live stream yet) looked perfect on its own,
and only *resuming* a stream after it exposed the seam.
**Deliberately left simple at the time, later fixed** (`app.rs`'s
module doc has the full account): every incoming SSE event used to
refold the session's whole item list and rebuild the entire
right-hand widget tree from scratch on every event, rather than
reaching for `TranscriptScreen::push_row`'s incremental append --
`push_row` could only add a new row, and a streaming reply is
exactly a row whose text keeps changing after it first appears.
Fine at the size a desktop session's conversation is; wrong for a
long, fast-streaming one -- fixed below (this same box's "Streaming
no longer costs a full rebuild" entry) by giving `transcript-ui` a
`TranscriptScreen::apply` that updates a row already on screen
instead of rebuilding every row around it. `rebuild_transcript`
still runs the whole tree once, for a freshly loaded/selected
session and for `apply`'s own rare full-rebuild fallback. No history
paging (I3's job, reused as-is if this becomes permanent) and no
scroll-position preservation across a rebuild -- both still named
rather than silently missing, and neither depends on the fix below.
Background network I/O runs on plain `std::thread`s reporting back
through winit's `EventLoopProxy<AppEvent>` rather than iris's own
`Tasks`/`task_on`, because `Tasks` only requests a redraw once after
its whole async closure finishes, which fits "one request, one
update" and not a live stream that needs a redraw after *each*
event it relays.
Verification: `cargo fmt --all`, `cargo clippy --workspace
--all-targets` (zero warnings), `cargo test --workspace` from
`iris/` (7 new tests in `desktop-app` -- 4 for
`config.rs`'s save/load/permissions/corruption, 3 for `app.rs`'s
transcript folding and the resume-cursor regression above -- plus
the existing 37 unchanged), and `./run-tests.sh` at the repo root
(127 passing, `client-core` alone 93 -- the `EnrolledServer` parsing
tests already existed before this box). Android is untouched by
this step, as asked.
- [x] **E5 — the packaging xtask (2026-09-05).** Both pass-condition
proofs held on this checkout's own emulator: `adb install -r` of the
xtask-built APK over the Gradle-built one succeeded, and the
notification service reached its follow-loop and posted a real
notification while the app was backgrounded. `cargo xtask apk` at
the repo root (`.cargo/config.toml`'s alias for `cargo run
--manifest-path xtask/Cargo.toml --`) runs `cargo ndk` →
`javac`/`d8` → `aapt2` → `zipalign` → `apksigner` with no Gradle
driving the packaging itself -- one disclosed exception, below.
*Where it lives.* `xtask/` (new, independent crate at the repo
root -- **no Cargo workspace**, matching every other crate here;
`run-tests.sh` already `cd`s into each rather than assuming one).
**Zero dependencies**: every step is "run this SDK/JDK tool with
these arguments and check its exit status," which needs nothing a
crate would add (AGENTS.md's "new dependencies need a reason").
`src/sdk.rs` finds the SDK root/build-tools/`android.jar` the same
way `app/android-env.sh` does ($ANDROID_HOME, then
$ANDROID_SDK_ROOT, then `~/Android/Sdk`); `src/keystore.rs`
finds-or-generates the release key with the exact recipe
`app/build-apk.sh` uses (same env vars, same path, same `keytool`
invocation) so the two tools sign with the *same* key, plus a
`--debug` path using the conventional `~/.android/debug.keystore`;
`src/apk.rs` is the pipeline itself, `src/main.rs` the ~40-line CLI.
About 420 lines total against RUST.md's earlier "about 150" guess --
the difference is almost entirely dependency handling (below), which
the earlier estimate didn't anticipate.
*`:link`'s `kotlinc` question, resolved.* Checked first, since E3
left it open: no standalone `kotlinc` exists on this machine (not on
PATH, not under any SDK -- only `kotlin-compiler-embeddable` jars
inside Gradle's own distributions). So the choice was never
"invoke kotlinc" versus "port `ServerStore`/`ServerSettings` to
Java" as originally framed -- a third route fell out of solving the
*other* open dependency problem (androidx, next paragraph): the one
Gradle call already needed for that also compiles `:link`'s Kotlin
as a side effect, via Gradle's own embedded compiler, and hands back
the resulting `classes.jar` in the same resolved-jars list. That is
RUST.md's own "prebuild it once into a jar/aar E5 consumes as a
binary input" option, arrived at for free rather than built
specially -- no Java port of `ServerStore` was written, and
`android-shell/src/settings.rs`'s JNI class-by-name lookup
(`com/example/wgapplink/ServerStore`) needed no change.
*One disclosed exception to "no Gradle in the loop": dependency
resolution.* `app/shellApp` depends on `:link` (Kotlin, above) and
on `androidx.core:core-ktx` -- not a compile-time dependency of the
two Java stub classes (`MainActivity`/`NotificationService` import
only `android.*`), but a **runtime** one: `android-shell/src/notify.rs`
reaches `NotificationCompat`/`NotificationChannelCompat`/
`NotificationManagerCompat`/`ServiceCompat`/`ContextCompat` by class
name over JNI, so their bytecode has to be in the final dex even
though nothing in this pipeline's own Java source mentions them.
Reimplementing a Maven/AAR dependency resolver to avoid one Gradle
call was not a good trade against "smallest honest route" (the
standard this file already applied to `kotlinc`) -- so
`app/shellApp/build.gradle.kts` gained one task,
`printRuntimeClasspathJars`, which asks the `releaseRuntimeClasspath`
configuration for its artifacts through an `ArtifactView` requesting
the `android-classes-jar` attribute (the same post-AAR-transform
view AGP's own dexing task consumes, so an AAR is already unpacked
to a plain `.jar` by the time the xtask sees it) and writes their
absolute paths, one per line, to
`app/shellApp/build/xtask/runtime-classpath.txt`. `cargo xtask apk`
runs `./gradlew :shellApp:printRuntimeClasspathJars` once (a few
seconds, mostly UP-TO-DATE on a warm Gradle daemon), reads that file,
and hands every jar in it to `d8` as an ordinary program input --
`:link`'s `classes.jar` among them, per the paragraph above. Nothing
past that one call touches Gradle. **What this trades away**: the
pipeline is not Gradle-free end to end, only Gradle-free for the
part that was actually expensive (assembling and dexing the app's
own code, which the earlier options -- kotlinc, or a hand-rolled
resolver -- were the two ways to avoid entirely). Recorded here
rather than left implicit, matching how the `kotlinc` compromise
above is recorded.
*The rest of the pipeline, in order (`apk.rs`):* `cargo ndk -t
arm64-v8a -t x86_64 -P 26 -o app/shellApp/src/main/jniLibs/ build
--release -p android-shell` (both ABIs by default -- real phone and
this machine's emulator -- `--abi` overrides; always `--release`
for the native library regardless of the APK's signing variant, for
the reason E1 already established: a debug build's Vulkan
object-labelling segfaults this emulator's driver, and there is no
reason for a signing choice to make this crate's `.so` bigger).
`javac -cp android.jar` compiles `MainActivity.java`,
`NotificationService.java` and a freshly generated `PinnedCa.java`
(same template as the Gradle `generatePinnedCa` task, same
opening-quotes-adjacent-to-`"""` rule from AGENTS.md's "Things that
have bitten") into one `classes.jar` (`jar cf` -- `d8` rejects a
bare directory of `.class` files outright, "Unsupported source file
type", discovered by trying it). `d8 --release --min-api 24 --lib
android.jar` dexes that jar plus every classpath jar from the
paragraph above into one `classes.dex` (no multidex needed at this
size). `aapt2 link` compiles `app/shellApp/src/main/AndroidManifest.xml`
into the base APK's `resources.arsc` -- the checked-in manifest has
no `package` attribute (Gradle injects one from `android.namespace`
during a manifest merge this pipeline doesn't run), so `apk.rs`
writes a copy with `package="com.example.aiapp.shell"` spliced in
rather than editing the source manifest, and refuses to run at all
if the source ever gains one of its own (a version-drift guard
cheaper than a real merge). `--min-sdk-version`/`--target-sdk-version`/
`--version-code`/`--version-name` are passed on the command line for
the same reason -- the raw manifest carries none of them, Gradle's
`defaultConfig` normally does. `jar uf` (not a hand-rolled zip
writer -- `jar` ships with the JDK this pipeline already needs)
merges `classes.dex` and a staged `lib/<abi>/libandroid_shell.so`
tree into the base APK (cargo-ndk's `-o` writes
`jniLibs/<abi>/*.so`, matching the Gradle source-set layout it was
pointed at; Android's own zip convention wants `lib/<abi>/*.so` at
the archive root, hence the staging copy rather than an in-place
rename). `zipalign -f -p 4` then `apksigner sign` finish it, signed
with `~/.config/ai-app/release.jks` by default or
`~/.android/debug.keystore` under `--debug`. The signed APK is
copied to `xtask/build/outputs/apk/<mode>/ai-app-shell-<mode>.apk`
as a final step -- a Gradle-shaped path (`*/build/outputs/apk/*/*.apk`)
chosen so Dev Updater's fixed-pattern APK discovery
(`~/repos/dev-updater/server/src/discover.rs`'s `APK_PATTERNS`,
which has no per-component path override) finds it without any
change on that side; the working files above it stay under
`target/xtask/apk/`, an ordinary build-cache location (gitignored,
along with `xtask/target/`).
*Wired into `.dev-updater.ron`*: a second `Apk` component, `shell`,
`build: "cargo xtask apk"`, `modes: ["release", "debug"]`, no `cwd`
(defaults to the checkout root, which both the `cargo xtask` alias
and the publish path above need -- `.cargo/config.toml`'s alias
resolves its `--manifest-path` relative to the *invoking* working
directory, not to where the config file lives, which is what ruled
out giving this component its own `cwd`). Dev Updater's `ByMode`
appends the chosen mode word as the command's last argument
(`build-apk.sh`'s own interface, per that component's comment), so
`main.rs` accepts bare `release`/`debug` as well as `--release`/
`--debug` for typing by hand. The existing `app` component
(`build-apk.sh`, Gradle) is untouched.
*Verification.* `cargo fmt -- --check` and `cargo clippy
--all-targets` clean, zero warnings, for `xtask` (host target --
nothing in it is Android-specific; it *runs* `cargo ndk`, it isn't
cross-compiled itself). `./run-tests.sh`: 127 `server` + 88
`client-core` tests, unaffected, still passing. `apksigner verify
--print-certs` on the xtask's release output confirms a V3 signer
with `CN=ai-app` -- the same key `build-apk.sh` generates.
*The two pass-condition proofs*, both on this checkout's own AVD
(`ai-app-2`, GPU host, brought up and torn down within this
session):
- **Installs over the Gradle-built one.** Built the Gradle release
variant first (`AI_APP_KEYSTORE=~/.config/ai-app/release.jks
AI_APP_KEYSTORE_PASSWORD=$(cat
~/.config/ai-app/release.jks.password) ./gradlew
:shellApp:assembleRelease` -- needed its own signing block added
to `app/shellApp/build.gradle.kts`, copied from `androidApp`'s,
since `shellApp` had none before this), installed it fresh
(`adb uninstall com.example.aiapp.shell` first -- an older debug
install from E3 testing was signed with a different key and
`install -r` over it fails loudly with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`, which is the correct,
expected failure for a mismatched key rather than a bug), then
`adb install -r xtask/build/outputs/apk/release/ai-app-shell-release.apk`:
**`Success`**.
- **The notification service starts.** Enrolled via
`adb shell "am start -a android.intent.action.VIEW -d
'aiappshell://enroll?host=10.0.2.2&port=<sandbox port>&token=<token>'"`,
force-stopped the app, then re-launched it once (enrollment calls
`sync()` from `MainActivity.onCreate`). `adb logcat` shows
`ActivityManager: Background started FGS: Allowed ... intent:
... cmp=com.example.aiapp.shell/.NotificationService`, immediately
followed by `android-shell: jni::vm::java_vm: Attached thread
ai-app-notifications`, a real TLS handshake to the sandbox's
`10.0.2.2:<port>`, and `Response { status: 200 ... }` on
`/notifications`. Pressed home, spawned a sandbox session and sent
it `/question Should E5 proceed?`; `adb shell dumpsys notification
--noredact` then shows a live `NotificationRecord` for
`com.example.aiapp.shell`, `channel=sessions`, `tag=<session id>`
-- posted while the app held no visible activity, the same bar
E3's own proof cleared.
*Left undone, honestly.* No attempt to shrink the dex (R8/minify is
off, matching `shellApp`'s existing `isMinifyEnabled = false`, so
the APK carries the full unshrunk `androidx`/Kotlin-stdlib/coroutines
graph -- about 5.2 MB signed with both ABIs, most of it native
libraries and that dependency graph rather than this project's own
code). No `--abi arm64-v8a`-only real-device install was attempted
this session (no physical phone reachable from here); the emulator
proof above is `x86_64` plus a cross-compiled but unexercised
`arm64-v8a` `.so` in the same APK. Multidex is unneeded at today's
size but nothing in `dex()` checks for the 64k-method ceiling should
the dependency graph grow.
### The iris track
These build iris up to carry the app. Each is a feature added to iris
with a pass condition, in dependency order. Work in `iris/` in this
repository on the `rustify` branch, and record in this file what each
step measured.
- [x] **I0a — where iris lives (decided 2026-09-04).** For now it is
**vendored at `iris/` in this repository**, history not carried,
and consumed by path. Iris's decision: keep it close while it is
being reshaped for this app, and give it back its own repository —
`iris/iris` on the gitea remote, which already holds the full
244-commit history, on a branch of its own — once it has proved
itself. The vendored tree is that repository's `main` at
`7b54aaf` ("readme", 2026-01-29), byte-identical to the public
GitHub copy, so a later reconciliation has a known base. A crate
that uses it says `iris = { path = "../iris" }`.
- [x] **I0b — make it build here (done 2026-09-04).** iris now builds,
clippy-clean and rustfmt-clean at the defaults, on a pinned dated
nightly, and the `tabs` example draws on this VM's GPU.
**The pin** is `nightly-2026-09-03` (rustc 1.100.0-nightly,
`2e2b193f8`), declared in `iris/rust-toolchain.toml` along with the
`clippy`/`rustfmt` components and the two Android targets, so a
fresh clone provisions itself. It is dated rather than `nightly`
because the whole failure below was a rolling channel moving under
an unattended build. Installed with `--profile minimal`: 912 MB.
**The 36 errors were one syntax change, and the earlier diagnosis in
this file was wrong.** It is not that a trait must now be declared
`const trait` — the vendored tree already declares them that way,
which is how it was written in January. What changed is the *impl*
keyword order: `impl const Trait for T` is now
`const impl Trait for T`, and generics go on the `impl`
(`const impl<T: [const] Foo> Bar for T`). Bounds are unaffected;
`T: const Foo`, `T: [const] Foo` and `impl const Foo` in argument
position all still compile. Everything else — the unresolved
`UiVec2`/`Vec2`/`impl_op` imports, and a `Color<u8>` that resolved
to `wgpu_types::Color` — cascaded from the seven files that failed
to parse. The rewrite was mechanical across 20 sites and took the
workspace from 36 errors to 0.
**`#![feature]` gates, 12 after this step** (two were declared and
unused, and were removed: `map_try_insert`, `const_cmp`).
Load-bearing and worth watching: `const_trait_impl`, `const_ops`,
`const_convert`, `const_destruct` are the const-traits family and
the one that has already broken once — they move together, so
advancing the pin means re-reading this section. `unboxed_closures`
+ `fn_traits` (postfix builder API) and `unsize` +
`coerce_unsized` (widget handles) are pairs. The rest are
individually small: `macro_metavar_expr_concat`, `portable_simd`,
`associated_type_defaults`, `option_into_flat_iter`, and `gen_blocks`
in the top crate.
**Running it headless.** `iris/run-headless.sh EXAMPLE [--shot PNG]`
with `iris/headless.conf`, the same trick `emu` uses: a headless
sway, and `grim` for the picture. It deliberately starts its *own*
compositor rather than joining `emu`'s — sway tiles, so adding a
window to the one an emulator sits in resizes that emulator.
Unlike `emu`'s it disables Xwayland, since winit speaks Wayland.
**This VM has a real GPU for this**: Vulkan 1.4 through Venus onto
the host's RX 7900 XT, and GL 4.6 through virgl — so desktop wgpu
work here is not software-rasterised, unlike inside the emulator.
**iris has no tests at all** (`cargo test --workspace`: 0 passed
across 6 targets). Nothing to keep passing, and nothing to catch a
regression — worth knowing before I1 changes the text stack.
**`iris-core` no longer depends on winit, and now cross-compiles to
Android.** It wanted exactly one thing from it — `PhysicalSize<u32>`
in `UiRenderNode::resize`'s signature, for two numbers it immediately
turned into floats — and that pulled a whole windowing backend into
the layer below it, the wrong direction. `resize` takes
`impl Into<Vec2>` now, like `UiRenderState::resize` beside it already
did. The consequence is the point: with winit in the graph an Android
build of the core failed in `android-activity` (which needs a backend
feature nothing here selects), and without it
`cargo ndk -t arm64-v8a -P 26 build -p iris-core` finishes in 30s and
produces an rlib, wgpu's Android backend included. So **iris's
widget, layout and render core already builds for the phone**, and
what I2 has to supply is the surface, the input and the IME — not a
port of the library.
**Build weight, cold, on this VM's 8 cores** (`rm -rf target`, then
`cargo build --example tabs`), since "the Linebender stack is slow in
debug" was the worry behind this question: plain debug **43s** and a
2.1 GB `target/`; with the `[profile.dev.package."*"] opt-level = 2`
knob, **1m46s** and 1.5 GB. So iris's own wgpu + winit + cosmic-text
graph is not the slow thing — which makes it a calibration for E1
rather than an answer about Masonry, whose graph adds Vello, Parley,
Fontique and Skrifa. Runtime cost of the knob was not measured here.
**Fixed: iris never called `pre_present_notify`.** The symptom was
that about one start in five kept the window's 800x600 startup layout
on a 1920x1200 surface for good. What settled it was tracing iris's
own decisions into memory and dumping them from another thread —
`eprintln!` in the draw path makes the defect vanish, which is why
earlier attempts kept losing it. The traces from a good and a bad run
are **byte-identical**: both lay out and draw `redraw_all at
(1920, 1200)` into a 1920x1200 texture with `suboptimal=false`. iris
was drawing the right frame every time; the compositor was still
showing the first one, and forcing a full repaint did not shift it.
What was missing is winit's `Window::pre_present_notify`, called
immediately before `present`, which on Wayland is what ties the
commit to the surface's frame callback. Without it a frame drawn with
nothing following it can sit unpresented with nothing left to flush
it — which is exactly a window that has just settled after its
opening resize. Measured: **0 bad in 40** with the fix, against 4 in
20 before it, and — the stronger evidence — 0 in 20 in the
instrumented configuration that had been 15 in 20. Runtime resizing
still round-trips to a byte-identical layout.
Two things ruled out on the way, both worth not re-trying: the
present mode (the fault survived the move from `AutoNoVsync` to
`AutoVsync` at the same rate) and the size cache (`redraw_all` clears
it). A `desired_maximum_frame_latency` of 1 moved the rate without
fixing it, and was reverted. Iris's own note that she had never seen
the library fail to resize was the useful steer: it pointed away from
the layout code, where two hours had already gone.
One thing was fixed on the way, and it is not that bug: `update`
redrew everything when `resized` was set, but `needs_redraw` — which
is what decides whether to *ask* for a frame — did not know about
`resized` at all. The two now share one `needs_redraw_all`, since a
condition in one and not the other is a frame nobody requests. It is
latent on Wayland only because winit asks for a redraw after a resize
by itself; on Android, where the surface work of I2 will not have
winit underneath it, nothing else here would have asked.
- [x] **I1 — parley, and a glyph atlas (done 2026-09-04).** No bake-off:
Iris decided for parley directly ("I wanted to switch it to parley
anyways"), and then asked for the atlas as well ("just do the atlas,
commit to it, we do want it"). Both are in.
**What parley bought, beyond shaping.** Its editing model addresses
text by byte offset into one string, where cosmic-text used
`(line, index)` — so `select_content`, `delete_between`,
`insert_inner` and `newline` collapse into ordinary string
operations. Bigger: `Selection::geometry` and `Cursor::geometry`
replace `iter_layout_lines`, `index_x` and `cursor_pos`, which walked
runs by hand to place the caret and the selection boxes and were not
bidi- or wrap-correct. `edit.rs` lost about 130 lines and gained
Home/End. Its cursor motions map onto parley's `next_visual`,
`previous_visual_word`, `next_line` and so on, in one function.
**The atlas is what "text resizing (per frame) is really slow"
was.** Every string used to be rasterised into its own `RgbaImage`
and uploaded as a whole texture whenever anything about it changed,
so a window resize re-rasterised and re-uploaded every visible
string. Now a glyph is rasterised once per font, size and subpixel
phase, shared by every string that contains it, and a resize
re-emits quads without touching the GPU's copy. **The tabs example
reports it: `views`, the number of texture views bound, went from 6
to 1** — six per-string textures became one shared page. Supporting
pieces: a `GLYPH` primitive that samples a sub-rectangle and tints
it (the existing texture primitive samples a whole texture), a
`Patch` texture update so a new glyph costs its own bytes rather
than a 4 MB page, and `GpuTextures` keeping its `Texture`s, since a
view cannot be written through.
**Not yet measured**, and the honest gap in this step: the TODO's
"really slow" was never given a number, so neither is the
improvement. What is evidence rather than argument is the view count
and the shape of the work — a resize no longer rasterises. A
before/after timing wants the transcript screen of I5 to be worth
taking.
**Two bugs found on the way**, both pre-existing: `primitives!`'s
`@count` rule recursed comma-separated while matching
space-separated, so it terminated only for exactly two primitives
and adding a third hit the recursion limit; and `Color` had no
`Default`, which parley's `Brush` requires.
**Fourteen tests**, iris's first. The editor is the one part that is
pure logic rather than something needing a GPU and a window, and it
was rewritten wholesale with no way to exercise it — synthetic input
does not reach a client under the headless compositor, which has no
seat devices. Two of the tests are aimed at what the rewrite could
plausibly have broken: the IME preedit path, and editing multi-byte
text now that offsets are bytes.
Dropping cosmic-text and unicode-segmentation also retired two
nightly gates — `portable_simd` (the old glyph compositing) and
`gen_blocks` (the deleted line iterator). **Eleven left.**
### iris's binding array does not survive real Android hardware (found 2026-09-04, resolved 2026-09-04)
**Resolved the same day**: see "Where things stand" above and
TEXTURES.md's "Implemented, 2026-09-04". The measurement and sourcing
below are unchanged and are why the fix looks the way it does; nothing
here needs re-checking on its own account.
Iris asked, of the "unknown number of images" case — a transcript with an
unbounded number of attached screenshots — whether iris's approach even
works on a phone, since her recollection was that mobile does not support
it. Checked rather than assumed, and the recollection is right, with
sources rather than a guess.
**What iris does today.** Every texture — every `Image` widget
(`src/widget/image.rs`) and every glyph atlas page — gets its own
permanent slot in one array via `Textures::add`
(`core/src/primitive/texture.rs:65`), and both the `TEXTURE` and `GLYPH`
primitives sample it by `view_idx` into `binding_array<texture_2d<f32>>`
at `core/src/render/shader.wgsl:56`, sized by `UiLimits::default` — 100,000
textures, 1,000 samplers (`core/src/render/mod.rs:347`). That needs three
wgpu features: `TEXTURE_BINDING_ARRAY`,
`SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`,
`PARTIALLY_BOUND_BINDING_ARRAY` — Vulkan's `VK_EXT_descriptor_indexing`
("bindless"), promoted to core in 1.2. So a transcript with an unbounded
number of images is exactly the case that grows this array without bound,
one permanent slot per image.
**Measured first on the emulator, and it fails outright.** A rig
(`rigs/gpu-probe`, a plain executable with no window, pushed with `adb
push` and run from `/data/local/tmp` — no APK needed to ask a device what
it supports) asks `wgpu::Adapter::request_device` for exactly iris's
features and limits. Against the emulator's guest Vulkan — both
SwiftShader (`vk_swiftshader_icd.json`) and lavapipe (`lvp_icd.json`,
cold-booted) — `request_device` **fails**: `Unsupported features were
requested: TEXTURE_BINDING_ARRAY |
SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING |
PARTIALLY_BOUND_BINDING_ARRAY`. A second, raw query through `ash`
(`rigs/gpu-probe/src/vk.rs`, bypassing wgpu) shows lavapipe's
`vkGetPhysicalDeviceFeatures2` actually reporting all seven descriptor-
indexing sub-features as `true` at device api version 1.3 — so on this
software renderer wgpu-hal's own feature detection is being more
conservative than the driver, for a reason not chased further (a likely
instance-version negotiation gap, since `VK_EXT_descriptor_indexing` was
only promoted to core at 1.2 and wgpu-hal's own `Instance::init` may be
requesting less). That part is an emulator/wgpu-hal question and not the
finding that matters.
**The finding that matters is about real phones, not the emulator, and it
is sourced rather than recalled.** The **Android Vulkan Profile 2025** —
Google and Khronos's current baseline, covering **80.1% of active
Vulkan-capable Android devices** as of October 2025
([developer.android.com/ndk/guides/graphics/android-vulkan-profile](https://developer.android.com/ndk/guides/graphics/android-vulkan-profile))
— does **not** require `VK_EXT_descriptor_indexing` or any descriptor-
indexing feature. It requires `shaderSampledImageArrayDynamicIndexing`
(indexing an array of samplers by a value uniform across the invocation —
Vulkan 1.0 baseline, unrelated to bindless) and stops there; the same is
true of the 2021 and 2022 profiles. On the hardware side, Arm's own
developer documentation states **"`VK_EXT_descriptor_indexing` is
supported on all Valhall and 5th Gen GPUs"**
([developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus](https://developer.arm.com/mobile-graphics-and-gaming/vulkan-api-best-practices-on-arm-gpus)) —
Mali generations from roughly 2019 (Mali-G77) onward, named affirmatively
with no claim made for Bifrost, Midgard or Utgard, which are still common
in budget and older Android phones still in use. So this is not a
software-renderer artifact: a real, currently-shipping share of the
Android fleet lacks the feature iris's texture pipeline asks for
unconditionally, and the newest official baseline does not promise it
either. (A crates.io/search-engine claim of "1% support on Android" for
this extension was checked against its cited source, an Arm blog post,
and was not actually there — that number does not appear anywhere primary
and should not be repeated; the 80.1%-baseline-excludes-it finding above
is the one with an attributable source.)
**Recommendation, not yet implemented.** iris already solved the
identical problem for text in I1: the glyph atlas
(`core/src/render/atlas.rs`) packs many small rasters into a handful of
shared 1024×1024 pages and samples them by UV offset, so **text needs
none of the three features above** — only ordinary single-texture
sampling. The same technique generalizes to images: route an `Image`
widget through a shared atlas when it is small enough to pack (thumbnails,
downscaled attachment previews, avatars, icons), and fall back to one
ordinary, non-array texture bind group — selected per batched draw call
the way every immediate-mode 2D renderer already does — for anything too
large to atlas well (a photo opened at full resolution). Either path is
plain Vulkan 1.0 / GLES texture sampling, so it removes the descriptor-
indexing requirement from iris's device request entirely, which is also
what would make the emulator work regardless of the wgpu-hal question
above: a device that never asks for the feature cannot be refused for
lacking it. This is a change to iris's rendering core — the shader's
binding group layout, `Textures`, the texture and glyph primitives, and
`ui/painter.rs` — so it is written here as a recommendation rather than
started, per the project's rule to confirm a load-bearing design change
before making it. **It should be resolved before I2 is called done**,
since I2's pass condition is the phone, not just the emulator, and this
is exactly the kind of thing that passes on a desktop GPU and fails
silently on real hardware.
- [x] **I2 — iris on android-view (2026-09-05).** The android-view backend,
the `iris-android-app` cdylib and Gradle shell, insets, the back
gesture and the full `InputConnection` bridge are in and measured
working; the tabs example now renders on the emulator (Vulkan/
SwiftShader and GLES/virgl both), and the composer's keyboard shows
real Gboard suggestions through the IME bridge. See below for the
render-gap root cause and fix.
**Layout.** `iris/src/android/` mirrors `default/`'s module split
(`view.rs` is `app.rs`+`state.rs` combined, since android-view has one
harness type where winit splits `ApplicationHandler` from per-window
state; `render.rs`, `input.rs`, `attr.rs` correspond directly;
`ime.rs` and `insets.rs` have no winit counterpart). What used to live
only in `default/` and had no winit dependency — `WidgetState`,
`CursorState`/the sense machinery, `Tasks`, `Selector`/`Selectable`'s
focus handling — moved to crate-root modules (`state.rs`, `sense.rs`,
`task.rs`, `attr.rs`) so both backends use one copy; `Tasks`' redraw
nudge is now behind a `RequestRedraw` trait (`Window` for winit, a
`JavaVM`+`GlobalRef` attach-and-call for android-view) rather than a
concrete `winit::window::Window`. `winit`/`arboard` and
`android-view`/`send_wrapper` are now `[target.'cfg(...)']`
dependencies, and `default`/`android` are target-gated modules,
because winit's own Android support needs `android-activity` with a
backend feature selected — exactly what `iris-core` was kept free of.
Confirmed by trying it before the split (`cargo ndk -t x86_64 -P 26
build -p iris` failed inside `android-activity` itself) and after
(clean). `iris/tabs-ui` is the tabs example's widget tree factored out
of `examples/tabs/main.rs` into a crate generic over `Rsc: HasEvents`
+ `Rsc::State: FocusHost`, so the winit example and
`iris/android-app` (the new cdylib, excluded from the `iris` workspace
because android-view needs the NDK sysroot to link — see that
`Cargo.toml`'s comment) call the same `build()`.
android-view pinned to `bec6c62a96cef8239b0fd7fedeef9b184d02e3a1`, the
commit E1 measured against. `RustView.java`/`RustInputConnection.java`
are vendored (no published AAR to depend on) into
`iris/android-app/app/src/.../org/linebender/android/rustview/`, with
one deliberate diff from upstream noted in a comment: `mViewPeer` is
`protected` rather than package-private, so `IrisView` (a different
package) can pass it to the window-insets native call android-view
has no hook for.
**Insets and the back gesture**, both without touching android-view.
The back gesture takes no new plumbing at all: with no
`OnBackPressedCallback` registered, Android still delivers it as an
ordinary `KEYCODE_BACK` `KeyEvent` through the existing key path (the
legacy behaviour every view-based app gets by default), handled in
`view.rs`'s `on_key_down`. Insets have no such stand-in, so
`android/insets.rs` registers one more native method
(`applyWindowInsetsNative`) directly on `IrisView`, writing into an
`Rc<RefCell<Shared>>` a second copy of which lives in
`AndroidUiState` — the peer id android-view hands back from
`register_view_peer` is opaque outside that crate, so this is a
side table keyed on the same id rather than a way to reach the peer
itself. `MainActivity` wires `setOnApplyWindowInsetsListener`,
including the API 30+ `ime()` inset specifically (falls back to 0
below that). Not yet consumed by any widget's layout — `insets()` is
exposed on `AndroidUiState` but nothing reads it yet, since the tabs
example has no chrome that needs to avoid the keyboard.
**The IME bridge is implemented and its pass condition holds.**
`android/ime.rs` implements the full `InputConnection` trait
(`text_before_cursor`/`after_cursor`/`selected_text`,
`cursor_caps_mode`, `delete_surrounding_text[_in_code_points]`,
`set_composing_text`/`_region`, `finish_composing_text`,
`set_selection`, `begin`/`end_batch_edit`, `send_key_event`,
`request_cursor_updates`) directly against `TextEdit` — the same
preedit-replace bookkeeping `default`'s `Ime::Preedit` handling uses
(`compose_len`, in chars), with new byte<->UTF-16 conversion helpers
since parley (since I1) is byte-indexed and Java strings are not.
Two approximations, both commented in place rather than silently
dropped: `set_composing_region` declines (no separate composing range
exists to move) and `set_selection`/`delete_surrounding_text_in_code_points`
collapse to an approximation rather than a real span/code-point
count. `TextEdit` gained `text()`/`selection_range()`/`caret()`
getters and `TextEditCtx::delete_byte_range`/`set_cursor_byte`, all
unconditional (no winit dependency added); `apply_event`/
`TextInputResult`, which do take a `winit::event::KeyEvent`, are now
`#[cfg(not(target_os = "android"))]` instead of being ported, since
android's own `input.rs` calls `TextEdit`'s primitives
(`backspace`/`delete`/`motion`/`insert`) directly from
`ndk::event::Keycode` and never needed a winit `KeyEvent` shape.
**Measured on the emulator, 2026-09-05, x86_64 API 26,
`-feature Vulkan` + SwiftShader per the Vulkan section below.**
`adb shell dumpsys input_method` after tapping the composer field:
`mInputShown=true`, `mServedInputConnection` is
`org.linebender.android.rustview.RustInputConnection` attached to
`IrisView`. `adb shell input text "hi"` followed by a screenshot
shows **Gboard's suggestion strip populated with "hi | Hi | HI"** —
capitalization variants read back out of the real buffer through
`text_before_cursor`, the same kind of evidence E1 recorded (there:
"dolor | Dolores | door"). That is the bar this box asks for, met.
**Resolved 2026-09-05: the render gap was the window uniform, never
the atlas.** `UiRenderNode::new` (`core/src/render/mod.rs`) seeded the
GPU's `window_buffer` from `WindowUniform::default()` — width=0,
height=0 — and the only thing that ever corrected it was a later call
to `UiRenderNode::resize`, renamed `AndroidRenderer::resize` on the
android side. winit's backend gets away with the same default because
winit fires an initial `WindowEvent::Resized` before the first frame,
which `default/mod.rs`'s event loop turns straight into that resize
call — a real event this project never had to add on purpose, so
nothing here noticed the node depended on it. android-view has no such
automatic event: `surface_changed` (`src/android/view.rs:363-388`)
only calls `self.render.resize(...)`, which is
`UiRenderState::resize` — the CPU-side *layout* width the widget tree
lays out against — not `AndroidRenderer::resize`, which is the one
that writes the GPU uniform. `AndroidRenderer::new` builds a fresh
`UiRenderNode` with the correct `SurfaceConfiguration` (so the surface
itself was always the right size, and the clear colour reached it) but
that node's window buffer was never subsequently written, so it sat at
`(0, 0)` for the node's entire life. `shader.wgsl`'s `vs_main` divides
by `window.dim` to reach clip space
(`let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;`), so
every primitive's clip position came out `NaN`/`Inf` and was dropped
before rasterization on **both** backends — Vulkan and GLES alike,
exactly the cross-backend symmetry that should have pointed away from
a GL-specific cause sooner. The layout engine reporting the correct
widget count and pixel region the whole time is consistent with this:
that path never touches `window.dim` at all, since it is a separate
copy of the window size (`UiRenderState`'s own, fed by
`self.render.resize`) that the CPU-side layout and hit-testing use.
**The GLES `D2`/`D2Array` warning was confirmed a red herring.**
Reproduced again after the fix, unchanged, on a build forced to
`Backends::GL` — it fires on every frame regardless, and primitives
draw correctly on that backend anyway (screenshot below), so it is a
cosmetic wgpu-hal heuristic notice, not a correctness bug in the atlas
path. Left as-is; chasing it further is not warranted.
**Fix** (`core/src/render/mod.rs`, `UiRenderNode::new`): seed
`WindowUniform` from `config.width`/`config.height` — already the
surface's real size at construction time on both backends — instead
of `WindowUniform::default()`. This removes the dependency on an
external resize call entirely (winit's initial `Resized` event still
fires and still calls `resize()`, now idempotently) rather than
papering over android-view's missing event with one more call in the
android-specific path; a future third backend gets a correct window
buffer from its first frame with no equivalent event of its own to
remember.
**Verified on the emulator, 2026-09-05, `ai-app-2`'s own AVD, x86_64
API 26, `-feature Vulkan` + SwiftShader per the Vulkan section.**
`logcat` after launch: `render(): after update active=39
root_px=Some(PixelRegion { top_left: (0, 0), bot_right: (1080,
2219) })`, no wgpu validation warnings on the Vulkan build. Screenshot
(`/tmp/iris_i2_render.png`) shows the tabs example's coloured spans,
the red rounded rect and the tab bar all drawn — the milestone this
section asked for. Rebuilt with `Backends::GL` forced (reverted
afterwards; the shipped code still requests `Backends::PRIMARY`) and
reinstalled: same screenshot, same widgets, `AdapterInfo` logged as
`Android Emulator OpenGL ES Translator (virgl (AMD Radeon RX 7900
XT...` confirming the real GLES/virgl path, with the `D2`/`D2Array`
warning present and harmless as above. Text glyphs render with visible
artifacting on the GLES path specifically (not investigated further —
out of scope for this box, which is about primitives appearing at
all, and it does not affect the Vulkan path this app ships behind).
**Not built yet**: anything consuming `insets()`, a real phone
measurement (only the emulator so far — matches every other Android
finding in this file), and AccessKit (I4's job, so `ui-trace`
couldn't be used here; a raw `adb shell input tap`/`input text` stood
in for driving the UI, which is why this section says "the same bar
as E1" rather than citing a `ui-trace` transcript).
**Verification.** Host: `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, `cargo clippy --all-targets`,
`cargo test --workspace` (19 tests) all clean in `iris/`; `iris/run-headless.sh
tabs --shot` still renders pixel-identically (27266 bytes, byte-for-byte
unchanged). Android cross-compile: `cargo ndk -t x86_64 -P 26 build`
and `... clippy` clean for both `iris` (with the android module) and
`iris/android-app`. Emulator: `emu up` with
`VK_DRIVER_FILES=.../vk_swiftshader_icd.json` and
`GPU_HOST_FEATURES="-feature Vulkan -no-snapshot-load -no-snapshot-save"`
per the Vulkan section; `cd android-app && cargo ndk -t x86_64 -P 26
-o app/src/main/jniLibs/ build --release && gradle :app:assembleDebug`
(release native lib per E1's segfault finding, debug Gradle variant --
the jniLibs contents are what matters, not the Gradle build type);
`adb install -r app/build/outputs/apk/debug/app-debug.apk`. Emulator
torn down after verification (`emu down`) per the machine's memory
rule.
- [x] **I3 — a virtualised, bottom-anchored list (2026-09-05).** Variable-height
rows, keyed, composed only while visible, paged in both directions
with a "more" sentinel at each end, a scroll anchor that survives
rows being inserted above, and "hold the edge nearest the tap" done
in the layout pass. Built as `iris::widget::List`
(`iris/src/widget/list.rs`, its module doc is the design writeup) --
see `IRIS.md`'s 2026-09-05 entry for the public API and the one
correctness lesson worth carrying elsewhere (a fill-shaped background
cannot be measured at a throwaway oversized region and merely
`reposition`ed into place; it has to be placed at its cached real
size, or measured-then-redrawn via `draw_twice` on first appearance).
**Done**: the widget, 6 unit tests (`cargo test -p iris`, anchor and
edge-hold logic, all pure -- no GPU/window needed, same harness as
`layout_tests.rs`), `iris/benches/message_list.rs` rewritten to
measure the real widget instead of a hand-built `Span`+`Scroll`, two
new benchmark scenarios ((d) insert-above-anchor, (e)
expand-a-row-holding-its-edge), and `iris/examples/message_list.rs`
(800 rows, varied wrapped-text length, one in twelve with an image,
mouse-wheel scrollable) rendered via `run-headless.sh` and visually
verified (cropped with a throwaway PNG decoder, since this VM has no
image tooling -- see the commit for the crop script's shape).
**Numbers (2026-09-05, release, this VM), all flat across N =
100/1,000/10,000 as required:**
cd iris && ./run-bench.sh list
(a) first frame: ~12.3-12.9ms draws=80 rewrites=3 moves=0
(b) scroll, 200 ticks: 4.8-6.5ms draws=328 rewrites=12 moves=10131 (~0.025-0.033ms/tick)
(c) input grows, 40 lines: 8.9ms draws=1846 rewrites=102 moves=1195 (~0.22ms/line)
(d) insert-above-anchor, 200 pushes: 0.4ms draws=200 rewrites=0 moves=0 (~0.002ms/push)
(e) expand-hold, 40 growths: 0.10-0.11ms draws=119 rewrites=40 moves=15 (~0.003ms/growth)
(d) is the cleanest confirmation: 200 rows prepended one at a time
while scrolled to the loaded window's start cost 200 draws total (the
list widget's own redraw each push) and **zero** row draws or moves
-- none of the prepended rows ever entered the viewport, exactly as
the anchor-by-slot-index design predicts. (e) similarly stays tiny
and flat: growing one row 40 times, each preceded by `note_tap` at
its own edge, costs a total of 15 moves (the rows on the far side of
the held edge) regardless of how many thousand rows exist elsewhere
in the list.
**Verification.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`,
`cargo clippy --all-targets` (and `--benches --release` separately,
since benches aren't always covered), `cargo test --workspace` (25
passed) all clean in `iris/`.
**What remains — the emulator half of the pass condition, blocked on
the emulator being held by another session during this pass.** The
condition as written ("800 rows of real transcript text from the
sandbox scroll without a frame over the Compose baseline in
`transcript-bench.sh`, measured on the GPU emulator") needs the
transcript screen actually rebuilt on top of `List` (this box only
built and measured the widget in isolation, per the task scope) and
then driven through the real emulator rig. Once that screen exists,
the exact command is:
cd app && ./transcript-bench.sh -k # or without -k for a fresh session
# compare its render report against the iris build's equivalent
This is a genuinely separate step (wiring `List` into an actual
session screen, i.e. most of I5's work) rather than something this
box's scope could finish alone -- recorded here rather than left
silently undone.
- [x] **I4 — accessibility names via AccessKit, host half done and verified
2026-09-05; the emulator half done and verified 2026-09-05, same day
as I5's Android integration (see bottom of this box for the exact
run).** Built `iris_core::ui::access::AccessTree`
(`iris/core/src/ui/access.rs`) -- one flat AccessKit tree, a synthetic
`Role::Window` root with every **named** widget as a direct child.
Deliberately flat rather than mirroring iris's real widget nesting:
nothing upstream of a named leaf needs a node, since a screen
reader's traversal (and uiautomator's tap-by-name, this box's own
pass condition) works from each node's on-screen bounds, not from
tree structure -- and mirroring the real tree would rebuild
intermediate nodes on every resize of any container above a named
widget, which is most frames.
**Modular the way input's sense registry is.** `Widgets` gained one
`HashSet<WidgetId>` (`named`), populated only by `.label()`/
`set_label` and drained by `free_next` (the same removal path a
freed id already went through -- no second bookkeeping call added
anywhere). `AccessTree::update` walks `widgets.named()` directly,
never the full widget arena, so a widget nobody named costs this
subsystem nothing -- not a visit, not a branch. Roles come from a
new `Widget::access_role(&self) -> accesskit::Role` trait method,
default `Unknown`; the one override so far is `TextEdit` ->
`TextInput`/`MultilineTextInput` by `EditMode`. Bounds come from
`UiRenderState::window_region`, which sits on `resolved_region`'s
move-chain walk -- so a widget moved via `Offset`/`Scroll` (never
redrawn from scratch) still reports where it actually ended up; see
`bounds_follow_a_moved_widget_and_updates_stay_incremental` below.
**Incremental, not per-frame.** `AccessTree` keeps the last
`HashMap<WidgetId, Entry>` (name, role, bounds) it sent and only
returns a new `TreeUpdate` -- and only then bumps its `rebuilds`
counter, `take_rebuilds()`'s the AccessKit twin of
`UiRenderState::take_counters` -- when that set actually differs.
Confirmed by `bounds_follow_a_moved_widget_and_updates_stay_incremental`
(`iris/src/access_tests.rs`): 1 rebuild on the first draw, 0 across an
unchanged frame, 1 more after a real move, regardless of how many
other widgets are on screen.
**`SlotId::as_u64`** (`core/src/util/slot.rs`) encodes a `WidgetId`
into accesskit's flat `NodeId(u64)`, offset by one so a real widget
never collides with the reserved window node (`NodeId(0)`).
**Pushed through two backends, each behind an inert action/activation
handler** -- see below for why inert is correct, not incomplete.
`default/access.rs` (winit): `accesskit_winit::Adapter`, built in
`DefaultApp::new` with the window created hidden
(`with_visible(false)`) and shown only after the adapter exists,
which is what that constructor requires. `process_event` runs on
every `WindowEvent`; `update_if_active` runs once per
`RedrawRequested`, after `render.update()` so bounds reflect the
frame just drawn. `android/access.rs` (android-view):
`accesskit_android::Adapter` on `AndroidUiState`, `IrisViewPeer` now
implements `AccessibilityNodeProvider`
(`create_accessibility_node_info`/`find_focus`/`perform_action`), and
`render()` (now taking `&mut CallbackCtx`, needed for the JNI handle
any `raise` requires) pushes the same `AccessTree::update` after
every draw.
**Why the `ActionHandler`s are empty, not a placeholder for later
work**: AGENTS.md's own "Driving the UI" section says it plainly --
`ui-trace record --do "tap 'Save'"` resolves the label against the
screen and performs a **real touch at that node's bounds**, the same
as a person's finger. It does not call into AccessKit's action
system at all. So once `AccessTree` reports correct bounds, the
ordinary pointer path (already built, already tested) is what
answers the tap -- there is nothing for `do_action` to do for this
pass condition specifically. A future real screen reader's own
double-tap-to-activate gesture works the same way, for the same
reason. If iris ever needs to answer an AccessKit `Action::Click`
injected without a matching touch (e.g. a switch-access scanner),
that is new scope, not a gap in this box.
**E1's abort mitigation, carried.** `android/access.rs`'s
`raise_if_enabled` is the one place `QueuedEvents::raise` may be
called: it asks `AccessibilityManager.isEnabled()` (a `getSystemService`
JNI call, since android-view has no ready-made wrapper) immediately
before every `raise` and drops the events instead when the answer is
no. Every call site (`render`'s per-frame push, `perform_action`)
goes through it, and each pushes it as a *deferred* callback exactly
like android-view's own demo, so it runs after the current JNI
callback has released whatever it's holding -- `raise`'s own
documented requirement. Not independently re-triggered on this
pass (that needs the emulator, see below); the mitigation is coded
to the exact mechanism E1 diagnosed (`sendAccessibilityEvent`
throwing when accessibility is off) rather than to the symptom, so
there is no reason to expect it behaves differently here than it did
there.
**Verified, 2026-09-05, host only.**
`cargo fmt --all -- --check`, `cargo build --workspace --all-targets`,
`cargo clippy --all-targets` (both plain and `--all-targets`) clean;
`cargo test --workspace` -- 28 tests in `iris/`, three of them new
(`access_tests::a_named_widget_reaches_the_tree_with_its_role_and_bounds`,
`::a_widget_with_no_label_never_reaches_the_tree`,
`::bounds_follow_a_moved_widget_and_updates_stay_incremental`).
`cd iris/android-app && cargo ndk -t x86_64 -P 26 build` and
`... clippy` clean for both `iris` (with the android module) and
`iris-android-app`, same shape as I2/I3's checks.
`iris/run-headless.sh tabs --shot /tmp/iris_i4_tabs.png --seconds 4`
still renders -- **27266 bytes, byte-for-byte identical to I2's own
post-fix screenshot** -- confirming the hidden-window-then-adapter
change to `DefaultApp::new` cost nothing visible. `tabs-ui`'s five
switch buttons (`tabs-ui/src/lib.rs`) now carry `.label()`s matching
their on-screen text ("pad", "span", "image span", "text layout",
"text edit scroll") -- both so the desktop run above exercises a
non-empty tree and so the emulator step below has real names to tap.
Not independently checked on this pass: whether `accesskit_winit`'s
Linux path (AT-SPI, via `accesskit_unix`) actually reaches a real
assistive-technology client on this VM's headless sway -- there is
no AT-SPI registry running here, so `default/access.rs`'s handlers
are exercised as inert code paths (built, called, no panic) rather
than confirmed end-to-end the way the emulator step below confirms
the Android path.
**Done, 2026-09-05, on this checkout's own emulator (`ai-app-2`,
`EMU_GPU=software` -- see I5's box for why plain `-gpu host` and the
documented Vulkan-feature recipe both could not be used here).**
`cargo ndk -t x86_64 -P 26 -o app/src/main/jniLibs/ build --release &&
gradle :app:assembleDebug`, installed, launched, then each of
ui-trace record --do "tap 'pad'"
ui-trace record --do "tap 'span'"
ui-trace record --do "tap 'image span'"
ui-trace record --do "tap 'text layout'"
ui-trace record --do "tap 'text edit scroll'"
resolved (uiautomator found the exact label every time -- `ui-trace`
never failed a run). **Confirming the pane actually switched needed
more than `ui-trace show`**: the tabs row is the *only* named
structure on this screen, its five buttons never move, so
`--field box` reports "nothing moved" on every run whether the pane
behind it changed or not -- a screenshot before/after is what showed
it, `adb exec-out screencap -p`, hashed to confirm difference; also
confirmed live with a temporary `log::debug!` in `switch_button`'s
click closure (reverted before committing) showing the exact index
clicked matching the tapped label. The detach-abort check also
passed: six consecutive `ui-trace record` calls against the same
process (attach, detach, attach again, five more times) left it
alive throughout -- `adb shell dumpsys window` still showed
`dev.iris.android.demo/.MainActivity` focused and rendering
afterward, no crash in `logcat`.
**One real, unrelated bug found and fixed getting here, not part of
I4's own design**: the release build was required -- a debug/dev
profile build of this same APK reliably `SIGSEGV`s inside this
emulator's Vulkan loader (`vulkan.ranchu.so`,
`vk_common_SetDebugUtilsObjectNameEXT`) the moment `wgpu` creates its
first bind group layout, because `wgpu`'s `InstanceFlags::
from_build_config()` turns on debug object-labelling in a dev build,
and labelling a `SwiftShader`-backed resource through this
emulator's loader trampoline crashes. A release build's
`InstanceFlags::empty()` never takes that path. Nothing in iris
caused this and nothing here needed to change to avoid it --
recorded because it looked exactly like a fresh regression the first
time it was hit (mid-session, after adding an unrelated temporary
log line forced a dev rebuild) and cost real time to separate from
the actual touch-dispatch question being chased at the time.
- [x] **I5 — the transcript screen in iris (2026-09-05, updated later the
same day, and again 2026-09-05 with the clean scroll comparison).
The widget-tree half and the Android integration are both built and
confirmed working on-device (real server, real scrolling, real
touch-drag pan, tap-by-name), iris has its own frame-timing
instrumentation (`FrameReport`), and long-press-then-drag-to-select
is confirmed on-device (both by logcat and by a screenshot showing
the highlighted selection). Ticked `[x]` now that a clean,
single-session, like-for-like 24-swipe comparison against Compose
exists -- see "Clean scroll comparison, 2026-09-05" near the end of
this box for the numbers, what is and is not comparable between the
two, and the dropout finding (this pass's own script bug, not a
reproduction of the emulator touch-delivery candidate below).**
**Where it lives.** `iris/transcript-ui/` (new workspace member,
`[lib]`), the same shape as `iris/tabs-ui`: generic over `Rsc:
HasEvents` + `Rsc::State: FocusHost` so the same `build()` can run
under winit (`transcript-ui/examples/transcript.rs`) or an
android-view cdylib later. Depends on `client-core`/`event-model` by
path (real code, matching E2's precedent) and `pulldown-cmark`
(0.13.4, current stable). Four modules: `markdown.rs` (CommonMark ->
plain text + `Vec<SpanStyle>`), `row.rs` (one `iris::widget::List`
row per folded `TranscriptRow`), `selection.rs` (cross-row
selection), `composer.rs` (the growing input field). `lib.rs`'s own
module doc has the screen's shape and the one gap it documents up
front (below).
**New iris API, added in this box and recorded in `IRIS.md`:
`SpanStyle`, per-range text styling.** This is the actual answer to
RUST.md's E2 finding against Masonry ("rich inline text -- block-level
yes, inline no, and both for the same reason":
`masonry/src/widgets/text_area.rs:43-44`'s `TextArea::edit_styles()`
returns one `StyleSet` for the whole editor, with `// TODO:
RichTextInput` beside it). `core/src/primitive/text.rs`'s
`TextBuffer` gained `spans: Vec<SpanStyle>` and `set_spans`;
`SpanStyle{range, color, family, font_size, bold, italic,
underline}` pushes into parley's `RangedBuilder` via `.push(property,
range)` instead of only `.push_default(...)`, so one `TextEdit` can
carry a heading's bigger bold font, an inline-code span's monospace
colour, a link's colour+underline and an ordinary paragraph's base
style all in the *same* wrapped, selectable buffer.
`core/src/render/atlas.rs`'s `PlacedGlyph` gained a `color: UiColor`
field (read from parley's own per-run `Style::brush`,
`core/src/primitive/text.rs`'s `TextData::place`) and
`core/src/ui/painter.rs`'s `glyphs()` now colours each glyph from
that field instead of one colour for the whole `RenderedText` --
the change that actually makes a span's colour reach the screen.
**Real bug found and fixed while wiring this in**: `TextBuilder`'s
`.spans(...)` was only threaded through `TextOutput::run` (the
read-only `Text` widget), not the sibling `TextEditOutput::run` (the
`TextEdit` every transcript row actually uses) -- a "rule that
governs a set belongs to the set, not one member" miss, per
CODE_RULES.md; found because `run-headless.sh`'s screenshot showed
*no* styling at all despite `markdown.rs`'s own unit tests passing
(they only check the string/range logic, not the render path -- see
`iris/src/widget/text/build.rs`'s `TextEditOutput::run`, now fixed).
**The seven behaviours, each shown or given a sourced reason, same
structure as E2's own accounting:**
1. **Selection spanning rows -- shown, with a scoped shortcut
recorded rather than hidden.** `selection.rs`'s `Selection`
coordinates each visible row's own `TextEditCtx::select`/
`select_all`/`deselect` (already built for one field, I2) from a
single drag that crosses row boundaries: rows between the anchor
and the pointer get `select_all()`, the row under the pointer gets
a true partial selection from whichever edge faces the anchor,
and `selected_text()` concatenates the result in row order. The
one shortcut: the *anchor* row is selected in full once the drag
leaves it, rather than "from the click point to its far edge",
because that needs the row's own laid-out size and
`TextEditCtx`'s `layout()` helper is private
(`iris/src/widget/text/edit.rs`) -- see `selection.rs`'s module
doc. Pure range-membership logic (`in_range`, mirroring
`begin`/`extend`'s row-selection arithmetic) is unit-tested
without any render harness; the widget-level wiring is not
independently screenshotted this pass (would need a synthetic
drag injected into the winit example -- not attempted, time).
2. **Rich inline text -- shown, genuinely inline this time.**
`markdown::render_markdown` folds one row's whole markdown (not
one block at a time) into one string plus spans, so a heading, a
**bold** word, *italic* text, `inline code`, and a
[link](url) inside the same paragraph render in one `TextEdit`
that still wraps and selects as a single buffer --
screenshotted, see below. Deliberately not attempted, each
recorded at the point it would have gone in `markdown.rs`'s own
doc: a background chip behind inline code (needs glyph-run
geometry `TextEdit`-internal and not exposed, the same primitive
`TextEdit::draw`'s selection highlight uses,
`iris/src/widget/text/edit.rs:99`), a tappable link (same missing
primitive), a real table layout, and per-token syntax colour
inside a fence.
3. **Bottom-anchored virtualised list, hold-the-edge on expand --
shown**, reusing I3's `List` unmodified. A `TranscriptRow::Tools`
row collapses to "N tool calls" and expands to every call's own
tool/input/output on tap; `row.rs`'s click handler calls
`List::extent(key)` to convert the tap's row-local position into
the viewport-relative position `List::note_tap` wants, exactly
the two-step contract `list.rs`'s module doc describes for
`holdTopEdge`. Not independently screenshotted mid-expand this
pass (no input-injection into the desktop example was built) --
the mechanism is the same one I3 already benchmarked
(`expand-hold`, flat at 0.10-0.11ms across N), applied to real
content instead of a synthetic row.
4. **The soft keyboard -- inherited from I2, not re-investigated.**
The composer (`composer.rs`) is an ordinary `TextEdit` with the
same `InputConnection` bridge I2 built and measured (Gboard
suggestions over real buffer content); nothing new to add here,
and no Android shell exists yet for this screen specifically to
re-verify it against (see "What remains").
5. **Platform integration -- out of scope by design**, same as E2:
E3's list, not this box's.
6. **Accessibility names -- shown for the composer, not yet for
rows.** The composer field carries `.label("Message")` (I4). Rows
do not yet carry per-row labels (a row's own text *is* its
accessible content via `TextEdit`'s `access_role`, I4, but
nothing calls `.label()` on it, so `Widgets::named()` does not
include it) -- a small, real gap, recorded as an IRIS_TODO.md
item rather than silently left, since AGENTS.md's bench scripts
depend on exactly this for driving a screen by name.
7. **Measurable frames / the render-number pass condition -- the
gesture-conflict half is now fixed (2026-09-05); the emulator
half is still not attempted, and unlike E2 that's not an absent
gesture path.** `List` demonstrably scrolls (I3's flat
draws/moves, programmatic `scroll()`) and mouse-wheel scrolling
is wired here (`lib.rs`'s `CursorSense::Scroll` on `list`). What
was *not* reachable at first was a **touch-drag pan starting on a
row's own text**: `row.rs` registered `CursorSense::
click_or_drag()` on each row's `TextEdit` for selection, and
`TextEdit::draw` calls `painter.child_layer()`
(`iris/src/widget/text/edit.rs:87`), so `core/src/sense.rs`'s
`run_sensors` (which stops at the first layer, checked
innermost-first, that consumed the gesture) gave that row first
refusal on *every* frame it was pressed, not just the frame the
press started -- a row's drag-select won the same gesture a
list-level pan would want. This is a genuine, diagnosed
architecture gap this box's *own* two features created by both
wanting the same gesture -- not a missing primitive the way
Masonry's absent `on_pointer_event` drag handling was.
**Gap closed, 2026-09-05, same day.** `iris::sense::DragArbiter`
(`iris/src/sense.rs`, new public type, recorded in `IRIS.md`) is
one small state machine, one instance per gesture surface (a
whole list, not per row), driven with a caller-supplied `Instant`
so it needs no render harness to test. It decides the way
Android itself does, recorded in `DECISIONS.md`: an ordinary
vertical drag pans immediately; a stationary press held
`LONG_PRESS` (500ms) starts a selection, which any further drag
then extends; a horizontal drag while something is already
selected extends it immediately, skipping the wait.
`transcript-ui/src/selection.rs`'s new `Selection::drag` is the
one place every row's `CursorSense::click_or_drag() |
CursorSense::unclick()` handler now goes through (`row.rs`,
`build_text_row`), replacing the direct `begin`/`extend` calls
each row used to make on its own -- one arbiter shared across
every row is what keeps the decision consistent as a drag
crosses row boundaries, per `DragArbiter`'s own doc. `Pan(dy)`
calls the list's own `List::scroll` (the same method I3's
mouse-wheel handler and its own benchmark already use), so this
is not a second scroll mechanism. 8 new unit tests in
`iris/src/sense.rs`'s `drag_arbiter_tests` (vertical drag pans
immediately and keeps panning by per-frame delta; small jitter
under `DRAG_SLOP` stays undecided; a held press starts a
selection after `LONG_PRESS` and further drag extends it, even
vertical drag, once selecting; a horizontal drag with nothing yet
selected stays undecided rather than guessing; a horizontal drag
with something already selected extends immediately; a vertical
drag still pans even with a prior selection; release resets to
idle). Verification: `cargo fmt --all -- --check`, `cargo clippy
--workspace --all-targets` (zero warnings), `cargo test
--workspace` (28 pre-existing + 9 `transcript-ui` + **8 new**
`drag_arbiter_tests`, all passing), `cargo ndk -t x86_64 -P 26
build/clippy` for both `-p iris` and `-p transcript-ui --lib`
(clean), and `run-headless.sh transcript --shot ... -- -p
transcript-ui` -- byte-identical to this box's original
screenshot (38578 bytes, `cmp` confirms identical), confirming no
visual regression from the rewiring. **What this did not
attempt**: the emulator-side confirmation (a real touch swipe
over a row's text panning on-device) -- that still needs I5's own
Android integration, the one item named just above and in "What
remains" below; this pass only had the winit/host-side gesture
path to drive, since no cdylib exists yet for this screen.
**Verification, exact commands and results (2026-09-05, this VM):**
- `cargo fmt --all -- --check`: clean.
- `cargo build --workspace --all-targets`: clean, all six workspace
members (`iris`, `iris-core`, `iris-macro`, `tabs-ui`,
`transcript-ui`, plus the excluded `android-app`).
- `cargo clippy --all-targets` and `cargo clippy -p transcript-ui
--all-targets`: zero warnings.
- `cargo test --workspace`: 28 tests in `iris`/`iris-core` (all
pre-existing, unaffected) + **9 new in `transcript-ui`** -- 5 pure
markdown tests (`bold_and_italic_produce_spans_over_the_right_range`,
`heading_gets_a_bigger_font_size_span`,
`link_is_styled_and_keeps_its_visible_text`,
`fenced_code_block_is_monospaced`, a plain-text baseline) and 4
selection tests (forward/backward/single-row range arithmetic,
plus `unregister_forgets_the_row_and_clears_a_matching_anchor`
against a real minimal `TextEdit` in the arena, no window needed --
same harness style as `list.rs`'s own tests).
- `cargo ndk -t x86_64 -P 26 build -p transcript-ui` and `... clippy
-p transcript-ui --lib`: clean (`--lib` only -- the example uses
`iris::default`, winit-only by design, same as `iris/examples/
tabs`'s own example never having an Android build of itself; the
Android-facing entry point is a separate cdylib, not built this
pass, see below). `cargo ndk ... build -p iris` / `clippy -p iris`
also re-checked clean, since this box touched `iris-core`'s text
pipeline.
- `run-headless.sh transcript --shot ... -- -p transcript-ui`:
renders. Cropped for legibility (this VM has no image viewer --
see I3's own note on the same limitation and the throwaway crop
tool used here, not committed): a full conversation with a
**bold** word, *italic* text, `inline code` in its own colour, a
`# Sure` heading rendered visibly larger and bold, a coloured link,
a monospaced fenced code block, a collapsed "▸ 3 tool calls" row,
and the composer bar at the bottom -- every one of E2's markdown
screenshot's features, now inline within single paragraphs rather
than block-per-widget. Screenshots at `/tmp/iris_i5_transcript2.png`
(full) and crops there, not committed per the standing rule against
screenshots of real content leaving this repo -- these are
synthetic rows, but the rule is kept uniform regardless.
**The Android integration, done 2026-09-05.** Extended
`iris-android-app` (I2's shell) with a second, mutually-exclusive
`AndroidAppState` behind a new Cargo feature rather than building a
third shell -- see `iris/android-app/src/lib.rs`'s module doc for why
that was chosen over a standalone crate: the Gradle project, the
`IrisView`/`MainActivity` Java, and the `register_view_class` wiring
I2 already built are exactly what a second screen needs too, and the
only thing that differs is which `AndroidAppState` the JNI entry
point instantiates. `transcript_client.rs` (new) fetches the sandbox
server's session list, opens the first one, and follows it live --
`client_core::api`/`event_stream`/`transcript_fold` almost verbatim
from `desktop-app`'s `app.rs` (E4), down to the generation-guard
pattern; `fold_page`/`raw_seq` were hoisted into `client-core` itself
first so both callers share one copy rather than a second one being
pasted in (a separate small commit, "write the logic once").
Deliberately simplified, recorded rather than left to be
rediscovered: no session list UI and no enrollment flow exist for
this screen -- `build.rs` bakes the sandbox's host/port/token and the
pinned CA in at build time from
`AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA` env vars, same
trust-boundary reasoning as the Compose app's `GeneratePinnedCert`
Gradle task (`app/androidApp/build.gradle.kts`), extended here to
also bake the enrollment since building a real one is E3/E4's scope,
not this box's. A real app needs `desktop-app`'s
`EnrolledServer`/QR-link flow or E3's Keystore-sealed
`ServerConfig.kt`.
**Two real bugs found and fixed getting an actual screen on
screen, neither anticipated by this box's earlier design:**
1. **Missing `INTERNET` permission.** `iris-android-app`'s manifest
never needed one before this screen (the tabs demo makes no
network call), so nobody had noticed it was absent. Its absence
reads nothing like a network problem: `UreqTransport::new`'s
connect failed with `EPERM` ("Operation not permitted"), not the
`ECONNREFUSED`/`ENETUNREACH` a dead server or a firewall would
give -- a seccomp-level socket denial. Added
`<uses-permission android:name="android.permission.INTERNET" />`
with a comment naming the exact symptom, so the next person
hitting `EPERM` from this codebase's own `ureq` stack finds the
answer instead of debugging the server.
2. **A background task's first redraw request past the initial one
aborted the process.** `Tasks::redraw_handle()` (new, this box,
see `IRIS.md`'s 2026-09-05 entry for the full account) exists so
`transcript_client.rs` can ask for a frame after each
`TaskCtx::update`, the way `desktop-app` uses winit's `Proxy` for
the same reason. Calling it crashed with `SIGABRT`,
`Result::unwrap() on an Err value: JavaException`, inside
`android-view`'s `View::post_frame_callback` -- its Java side
calls `Choreographer.getInstance()`, which throws unless the
*calling* thread already has a `Looper`, and a tokio worker
thread has none even once JNI-attached. Fixed by routing through
`View::post_delayed(0)` instead (thread-safe, no `Looper`
required) and a new `IrisViewPeer::delayed_callback` override
(`android/view.rs`) that drains tasks and renders on the UI
thread the callback lands on -- same body as `do_frame`. Every
future caller of `redraw_handle()` from a background thread gets
this for free.
**The emulator itself needed a boot recipe none of the three
previously-documented ones give cleanly, found the hard way.**
Plain `emu up` (`-gpu host`, no Vulkan feature) crashed instantly --
`wgpu_core::instance: Request adapter didn't find compatible
adapters` -- this AVD's default boot has no Vulkan device at all,
matching "Vulkan in the emulator" below. The documented fix for
*that* (`VK_DRIVER_FILES=... GPU_HOST_FEATURES="-feature Vulkan" emu
up`) does get a Vulkan device, but on this host it is a **second**
one alongside the real GPU's own Venus/gfxstream Vulkan adapter, and
`AndroidRenderer::new`'s `request_adapter` (no adapter-name
filtering, `PowerPreference::default()`) picked Venus -- which
crashed inside `wgpu_core::device::resource::Device::
create_bind_group_layout`, the same structural Venus incompatibility
"Vulkan in the emulator" already documents for a different call.
`EMU_GPU=software` (`-gpu swiftshader_indirect`, no host GPU
involved at all) is what actually works cleanly, because there is
then only the one Vulkan device (`SwiftShader Device (Subzero)`) for
`request_adapter` to find -- confirmed via
`wgpu_core::instance: Found 1 compatible adapters`. **One trap in
switching between these**: the AVD's saved snapshot carries over
whichever GPU config booted it last, so restarting under
`EMU_GPU=software` right after a `-feature Vulkan` boot still linked
against `vulkan.ranchu.so` and crashed (`SIGSEGV` inside
`vk_common_SetDebugUtilsObjectNameEXT`) until the AVD's
`snapshots/` directory was cleared by hand -- matches "Vulkan in the
emulator"'s own note that a GPU-config switch needs a cold boot the
`emu` wrapper does not force. Recorded here rather than only in that
section since it is what made three different crashes look like
three different bugs before the pattern was the AVD's snapshot, not
the code.
**Measurements taken, 2026-09-05, `ai-app-2`'s own emulator,
`EMU_GPU=software`, against `app/ui-sandbox.sh` (port 8519, session
`8920378e7167ebcd`, 40 real sent/echoed messages):**
(a) **Tap-by-name on a named control -- passes.**
`ui-trace record --do "tap 'Message'"` (the composer's `.label`,
I4) resolved and the field's bounds moved (`top 2329 -> 1509`,
the keyboard opening), the same shape I4's own tabs-screen taps
confirmed the same day. `iris-android-app`'s tabs screen also got the
full I4 pass-condition run this session -- see I4's own box above,
now ticked `[x]`.
(b) **The `transcript-bench.sh`-shaped scroll comparison -- a
real number for Compose, no comparable number for iris, and that
gap is itself the finding.** `transcript-bench.sh` could not be
pointed at `iris-android-app` directly -- it reads the Compose app's
in-app render-report log line, which this screen has no equivalent
of -- so the same 24-swipe gesture loop (`swipe 540 700 540 1600
200` / `swipe 540 1600 540 700 200`, alternating, matching that
script's own cycle) was driven by hand via `ui-trace record` against
both apps, each freshly opened on the same session, `dumpsys gfxinfo
<package> reset` beforehand and `dumpsys gfxinfo <package>` after.
Compose: **8.96% janky frames, 99th percentile 150ms, 212 frames
rendered** over the loop -- worse than AGENTS.md's own recorded
stock-emulator baseline (5.2-5.9%, 29-32ms), consistent with
`EMU_GPU=software`'s CPU rendering being slower than the `-gpu host`
that baseline was taken under, which is exactly why AGENTS.md's rule
against reading an absolute emulator number as the phone's applies
doubly here. **iris: `dumpsys gfxinfo` reported 0 frames rendered
for the entire gesture window, on both a run where the screen
visibly did not move and one where it visibly did** (confirmed
by `adb exec-out screencap -p`, hashed before/after -- identical
when the swipe direction was already at that end of the transcript,
different once swiped the other way). **`gfxinfo` instruments
Android's own Skia/HWUI View-drawing pipeline; it has no visibility
into a `SurfaceView` whose contents are drawn by a separately-owned
GPU context (`wgpu`/Vulkan, here) the way Compose's ordinary `View`
tree is drawn.** A `dumpsys SurfaceFlinger --latency` probe against
the transcript screen's own `SurfaceView` layer was tried as a
fallback and returned only the display's refresh period (16666666ns)
with no frame history at all -- this Android version's BLAST
compositor does not keep the per-frame timestamps that legacy API
used to report. **So there is no dumpsys-derived frame-time number
for iris on this build**, not a bad one -- the honest comparison this
pass can make is functional (both apps' lists scroll under the same
touch gesture) rather than numeric, and getting a real number for
iris needs the app's own frame-timing instrumentation (the render
report the Compose side already has, iris has none of yet) rather
than a different `dumpsys` incantation.
(c) **Touch-drag pans the list on real device touch input --
confirmed by screenshot, not by `ui-trace show`.** `ui-trace show`
cannot answer this at all here: the only named node on this screen
is the composer, which does not move when the list scrolls, so every
`--field box` query reports "nothing moved" regardless of whether
the list actually did (the same "no named structure to track"
situation I4's tabs-screen note about clipped bounds warns about,
one level further -- here there is no candidate node at all, not a
clipped one). `adb exec-out screencap -p` before and after a single
`swipe 540 700 540 1600 300` (list not already at that end) hashed
different and visibly showed different message rows on screen;
the same swipe repeated when already at that end of the transcript
correctly hashed identical -- so the mechanism responds to real
touch, in both directions, not just once by luck. **Long-press then
drag to select was not independently driven this pass**: doing it
for real needs a touch held stationary for `LONG_PRESS` (500ms)
and *then* moved without lifting, and neither of `ui-trace`'s two
gesture primitives can produce that -- `tap` has no hold, and
`swipe X1 Y1 X2 Y2 MS` interpolates motion across its whole duration
from t=0, so a long `swipe` with a short first segment is still
continuous motion throughout, not a hold followed by a drag. This
needs either a new `ui-trace` action (a `hold MS then drag X Y`
primitive) or a raw multi-step `sendevent`/`MotionEvent` injection
neither this pass's tooling nor its remaining time could build
safely. `DragArbiter`'s own unit tests (`iris/src/sense.rs`, I5's
earlier "Gap closed" section) already cover this exact sequence with
a synthetic clock, which is why the mechanism is trusted enough to
call "not independently driven on-device" rather than "unverified."
**Update, 2026-09-05, later the same day: (b) has a real iris number
now, and (c) is confirmed on-device.** Both needed new tooling built
this pass, recorded in `DECISIONS.md`: `iris_core::FrameReport`
(`iris/core/src/render/frame_report.rs`) times each frame from
`render()`'s redraw start to after `queue.submit`+`present()` into a
fixed 4096-entry ring, exposed as two named controls on the
transcript screen ("Frame report", "Reset frame report",
`iris/android-app/src/transcript_client.rs`) logged under this
crate's fixed `android_logger` tag; and `ui-trace` gained a
`holddrag X1 Y1 X2 Y2 HOLD_MS MOVE_MS` action in `emulator-tools`
(press, hold, move, release as one continuous touch via the same
`MotionEvent`/`injectInputEvent` mechanism `swipe` already used),
closing the exact gap named above.
(b), continued: **a real iris number exists, but it is not the clean
24-swipe `transcript-bench.sh`-equivalent loop this box originally
wanted, for a reason worth recording precisely.** Driving the
gesture loop against a freshly-restarted app repeatedly produced
**zero** frames recorded (both by `gfxinfo`, already known, and now
also by `FrameReport` itself) even though the coordinates were
confirmed on-screen to sit over real row text (measured by scanning
a screenshot column for the first non-black pixel, not guessed) --
while the *same* coordinates driven a few commands later, or
combined into a slightly different sequence, sometimes produced 30+
real frames and a genuine screenshot diff. This is not the earlier,
already-understood "already at that scroll edge" case (AGENTS.md's
own note) -- it reproduced with fresh content confirmed taller than
the viewport, in both scroll directions, inconsistently across
otherwise-identical commands. The one measured correlate: this
checkout's own `EMU_GPU=software` emulator was independently seen at
**~78% of one CPU core, continuously**, while idle on-screen (`ps
aux` mid-session) -- SwiftShader's software rasterisation is
CPU-bound by design (AGENTS.md's Vulkan-in-the-emulator section), so
a synthetic touch's delivery to the SurfaceView competing with that
load is the leading candidate, not yet confirmed with a sampler
running *during* the gesture (the standing rule against diagnosing
from measurements taken after the fact applies here and this pass
did not have time to build that sampler). Recorded as a new,
distinct, unresolved finding in `IRIS_TODO.md` rather than folded
into the already-closed "no `hold`-then-drag primitive" gap.
**The number obtained, honestly scoped**: tapping "Frame report"
immediately after a run that *did* produce real scrolling frames
(screenshots differ, confirmed by hash) read
`frames=34 janky%=61.76 p50=26.5ms p90=48.0ms p99=98.1ms
worst=98.1ms` -- real, measured wall-clock time through iris's own
render path from a real on-device touch-drag, not a synthetic
probe, but accumulated across several swipe gestures across
multiple `ui-trace record` invocations rather than one clean
24-swipe loop, so it is **not directly comparable** to the Compose
figure below in scale, only in kind. Given the CPU contention
candidate above, a high jank percentage here is expected under
software rendering and should not be read as iris's number on real
hardware. The Compose figure quoted for reference
(**8.96% janky frames, 99th percentile 150ms, 212 frames rendered**)
is the same measurement this box already recorded on 2026-09-05
earlier the same day, under the same `EMU_GPU=software` config on
this same checkout's emulator -- **not re-taken this pass** (the
session's time went to building the two rigs above and diagnosing
the flakiness instead), and against a *different* sandbox session
(40 messages, id `8920378e7167ebcd`) than this pass's own (120
messages, id `c76b71d017a54589`), so the two numbers share
configuration but not identical content -- said plainly rather than
presented as a matched pair.
(c), continued: **confirmed on-device, by both routes the task
asked for.** `ui-trace record --do "holddrag 300 1850 300 2050 600
300"` (a 600ms hold, comfortably past `DragArbiter`'s 500ms
`LONG_PRESS`, then a 300ms move) against a row's real text produced,
in order: `iris selection: begin at row 3165`, then a sequence of
`iris selection: extend to row ...` lines as the drag crossed row
boundaries -- logged from `transcript-ui/src/selection.rs`'s
`Selection::drag` (new `log` dependency, smallest addition since
selection has no accessibility label of its own yet, per
`IRIS_TODO.md`'s existing gap). A screenshot taken right after shows
the expected highlighted selection spanning multiple rows,
confirming the mechanism visually as well as in the log. This is
the first time `DragArbiter`'s pan-vs-select decision has been
driven by a *real* Android touch sequence rather than only its own
synthetic-clock unit tests.
**What remains, named rather than silently dropped (also in
IRIS_TODO.md, dated 2026-09-05):**
- **Intermittent touch delivery under `EMU_GPU=software` CPU load**
-- new finding above. Needs a sampler running *during* a failing
gesture (load, `dumpsys input`, a frame-by-frame `ui-trace`
capture at `-i 0`) rather than another guess after the fact, and
ideally a comparison against `-gpu host` (real Vulkan, but shared
with whichever GPU config a peer session's emulator already
holds) to see whether it is specific to software rendering.
- **A clean, single 24-swipe `transcript-bench.sh`-equivalent
iris number** -- blocked on the above; the number this pass got is
real but not that clean run.
- **Row-level accessibility names** -- behaviour 6, I5's own writeup
above.
- **A tappable link and a code-span background chip** -- behaviour 2.
- **`Selection`'s anchor-row shortcut** -- behaviour 1.
- **No syntax highlighting inside a fenced code block** -- `markdown.rs`
notes `client_core::highlight` exists and could feed this.
- **`row.rs`'s tool-row expand and `selection.rs`'s cross-row drag
are not independently screenshotted/driven** -- covered by reading
and by the primitives they reuse (I3's `List` tests, this box's
own unit tests), not by a dedicated repro this pass.
**Net for the recommendation.** Item 3 ("decide when the transcript
screen exists in both, from the measurements") now has a real number
on the iris side for the first time -- `FrameReport` works, is unit
tested (6 tests over the ring/percentile math), and captured a real
on-device touch-drag's timing -- but that number is scoped narrowly
(accumulated over several gestures, not one comparable loop) because
of the intermittent-touch-delivery finding above, so it still cannot
be read against Compose's 8.96%/150ms figure as a clean comparison.
What *can* be said, updating the account further: iris's Android
integration, its frame-timing instrumentation, and its long-press
selection have all now been exercised by real on-device touch input
end to end (not only unit tests), on top of the structural points
E2 already found Masonry unable to reach at all (cross-row
selection, true per-span inline rich text). `DECISIONS.md`'s
DEFERRED item is updated with this session's numbers and the
touch-delivery caveat rather than a decision made here.
**Clean scroll comparison, 2026-09-05, one session, this checkout's
emulator, `EMU_GPU=software` only (the mode the existing Compose
figure above was taken under; a second pair under `-gpu host` was
not reached this pass -- see "Not attempted" below).** New content
for a fair pairing: a fresh sandbox session (`app/ui-sandbox.sh
spawn benchsession`, id `4d21d4a0d38f79fd`) with 30 identical sent
messages, each one heading/bold/italic/inline-code/link/list/fenced-
code paragraph, so both apps scroll the exact same bytes -- neither
of the two sessions quoted in this box's earlier passes (`8920378e
7167ebcd`, 40 msgs; `c76b71d017a54589`, 120 msgs) was reused, since
neither app had touched it. A sampler
(`date`/`/proc/loadavg`/`/proc/pressure/{cpu,io}`/top-5-by-CPU every
2s to `/tmp/iris-bench-sampler.log`) ran for the whole session,
started before either app was built, per the standing rule against
diagnosing a timing question from measurements taken after the
fact.
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst |
|---|---|---|---|---|---|---|---|---|
| Compose (in-app report) | debug | software | 1102 | 99.0% late | 33.8ms | 50.6ms | 79.5ms | -- |
| Compose (`dumpsys gfxinfo`) | debug | software | 1499 | 21.15% (95.66% legacy) | 32ms | 48ms | 150ms (99th) | -- |
| iris (`FrameReport`, run A) | **release** | software | 299 | 94.65% | 79.1ms | 98.6ms | 117.8ms | 212.6ms |
| iris (`FrameReport`, run B, repeat) | **release** | software | 233 | 94.42% | 109.3ms | 130.8ms | 147.1ms | 150.5ms |
Exact commands: Compose via `app/transcript-bench.sh` unmodified
(`open_session` then `copy_render_report` bracketing the standard
24-swipe/6-cycle loop, `dumpsys gfxinfo com.example.aiapp reset`
taken immediately before for the second row). iris via the same
24-swipe loop code -- extracted verbatim from
`transcript-bench.sh`'s `DO=""` .. `eval ui-trace record` block with
`sed`, not retyped, since `transcript-bench.sh` itself is Compose-
specific (opens by session title through the Compose app's own UI)
and could not be called directly -- bracketed by `ui-trace record
--do "tap 'Reset frame report'"` and `--do "tap 'Frame report'"`
(iris's own two named controls, I5's earlier "Update" section),
reading the result from `logcat`'s `iris frame report:` line.
**What each number counts, stated because the three are not the
same measurement.** Compose's in-app report times its own
Compose-internal phases (`total` = the full frame from Choreographer
callback to submit) and calls a frame "late" past a 16.7ms budget --
a stricter, self-reported definition. `dumpsys gfxinfo`'s "janky"
is Android's own HWUI/BLAST deadline-miss accounting, a different
threshold and a different frame population (it free-runs over
`Total frames rendered`, which includes frames from opening the
session and the report dialog, not only the swipe window -- hence
1499 vs. the in-app number's 1102). iris's `FrameReport` times
wall-clock from `render()`'s redraw start to after `queue.submit`+
`present()` -- i.e. iris's own render path only, nothing above the
GPU submit and nothing from Android's compositor -- confirmed
independently useless for iris via `dumpsys gfxinfo
dev.iris.android.demo`, which reported 1 total frame for the whole
run (unchanged from the earlier pass's finding: HWUI has no
visibility into a `wgpu`-drawn `SurfaceView`).
**Not comparable, stated plainly:**
- **Build profile differs by necessity, not choice.** Compose is
the **debug** variant (AGENTS.md's own bench-script requirement,
"the emulator scripts stay on the debug build"). iris is
**release** because I4's box already found the debug/dev profile
`SIGSEGV`s in this emulator's Vulkan loader the moment `wgpu`
creates a bind-group layout (`InstanceFlags::from_build_config()`
turns on debug object-labelling, which crashes against
`vulkan.ranchu.so`) -- there is no debug iris number to quote on
this rig. A release build is typically *faster* than debug, so
this asymmetry very likely understates how much worse than
Compose iris's own number would look built the same way Compose's
is, not the reverse.
- **The jank definitions and frame populations differ**, per the
paragraph above -- none of the three numbers is measuring the
same thing, so reading "94.65% > 21.15%" as "4x worse" is not
sound; only the general shape (iris's frames take longer, both by
its own accounting and by eye in the screenshots) transfers.
- **Both figures are emulator numbers under software rasterisation
(`EMU_GPU=software`/SwiftShader), not phone numbers**, per
AGENTS.md's and `this-machine-android`'s standing rule -- restated
because it applies doubly to iris's own number here: SwiftShader
is CPU-bound by design, and Compose's *own* in-app report shows a
14.6-22.2ms `swap` phase and a 20.8-33.9ms `gpu` phase alone (more
than the entire 16.7ms budget) under the same GPU mode, so a
software-rendering iris number well above 16.7ms is expected
going in and should not be read as an iris-specific defect
without a `-gpu host` pair to compare against.
**Where iris's time goes, from `FrameReport`/logcat -- not
optimised, per this task's own instruction, only described.** Two
things were checked because they were checkable without new
instrumentation: (1) **iris does not redraw while idle** --
`adb logcat -c` followed by a 3s settled wait produced *zero*
`iris::android::view: render()` lines, both before and after a
swipe; the render-per-frame spam only appears during and briefly
after a gesture (visible inertial settle), so there is no idle-
redraw tax to find here, unlike the composer-inset bug AGENTS.md
records for the Compose app. (2) **a swipe frame does not appear to
relayout the whole list** -- consecutive `render()` log lines during
a swipe show `active=88` falling to `83`, `78`, `73`, ... one small
step per frame, consistent with I3's virtualised list culling
widgets that scrolled out of the viewport rather than re-measuring
everything each frame (a full-list relayout would show `active`
constant at the total row count, not shrinking through it). Neither
observation isolates *where* the remaining ~80-150ms/frame actually
goes past those two rule-outs -- the leading remaining candidate is
the swapchain present/GPU path itself under SwiftShader's CPU
rasterisation, per the "not comparable" point above, but that was
not measured directly this pass (no per-phase breakdown inside
`FrameReport` the way Compose's report has `measure`/`place`/
`record`/`swap`/`gpu`).
**The dropout finding, corrected from earlier in this box: this
pass's own script bug, not a reproduction of the touch-delivery
candidate.** Of 3 planned attempts, the first 2 produced `iris frame
report: no frames recorded` -- but tracing it down found the cause
in this session's own tooling, not the emulator: the swipe loop was
extracted from `transcript-bench.sh` into a temporary wrapper script
that `cd`'d into `/tmp` before invoking `ui-trace`, and `ui-trace`/
`adb` here derive *which emulator to target* from the current
directory's basename (the per-checkout-AVD rule) -- from `/tmp` that
resolved to a nonexistent checkout named "tmp", `ui-trace` refused
immediately, and the wrapper's `set -eu` aborted the whole loop
before a single swipe was sent. Once the wrapper was fixed to run
from inside this checkout, the next **two** attempts (runs A and B
in the table above) both succeeded on the first try, each with a
confirmed screenshot-hash difference showing real scrolled content.
So this session did not reproduce the previously-documented
intermittent zero-touch phenomenon -- but two successes out of two
*valid* attempts is also too little evidence to say it is gone;
the earlier session's drops happened with a correctly-targeted
device, which is a different failure than the one found here.
**Sampler timeline**: `/tmp/iris-bench-sampler.log` shows
`/proc/loadavg` and the runnable-process count rising from an idle
baseline (~0.3-1.7 load, 0-4 running) to ~2.5-2.9 load and 11-23
running during the swipe window that produced run A -- consistent
with the standing candidate (SwiftShader's software rasterisation
loading the CPU during a gesture) but **not a confirmed cause**,
since both valid attempts succeeded despite the rise. Whether load
of that shape is what caused the *earlier* session's drops remains
unknown; this pass's sampler evidence neither confirms nor refutes
it, only shows the correlate is present under load without a
failure to correlate it to this time.
**Not attempted this pass**: the second `-gpu host` pair (time went
to the software-mode pair, the sampler, and diagnosing the dropout
above); a per-phase breakdown inside iris's own `FrameReport` the
way Compose's report has one; and syntax-highlighting/tappable-link/
accessibility-name gaps already named in "What remains" above,
unchanged.
**Verification for this update**: no Rust or Kotlin code changed
this pass (build/measurement only), so `cargo fmt`/`clippy`/`test`
were not re-run; `docs/DECISIONS.md`'s DEFERRED item is updated with
this table's headline numbers below rather than a decision made
here.
**Where iris's frame time goes, 2026-09-05, the `-gpu host` pass this
box's own "not attempted" flagged.** New code first: `FrameReport`
(`iris/core/src/render/frame_report.rs`) now splits each sample at
`queue.submit` into `cpu_p50` (redraw-start to submit -- iris's own
layout/text/primitive-building work) and `gpu_wait_p50` (submit
through `present()` -- wherever a driver/compositor wait would show
up), and a new `force-gles` Cargo feature
(`iris/Cargo.toml`/`android-app/Cargo.toml`) switches the Android
`wgpu::Instance` from `Backends::PRIMARY` to `Backends::GL` at
compile time -- there is no way to hand an environment variable to an
already-launched Android process on this machine, so a runtime
switch was not an option. `app/iris-scroll.sh` extracts
`transcript-bench.sh`'s exact 24-swipe/6-cycle loop for iris's own
demo app. Commit `e2a1fad`.
**Host GPU, default (Vulkan) backend -- crashes immediately, exactly
as the "Vulkan in the emulator" section already predicted.** Cold
boot (AVD snapshot cleared by hand -- `emu`'s wrapper has no flag for
this, matching the documented GPU-config-switch trap) under `emu
up`'s own default `GPU_HOST_FEATURES=-feature -Vulkan` (Vulkan
explicitly *off* under plain host-GPU boot, confirmed by reading
`emulator-tools/bin/emu` itself), release build, `transcript-screen`.
`dev.iris.android.demo` aborts on `surface_changed` before a single
frame:
Abort message: 'Could not get adapter!: NotFound { active_backends: Backends(VULKAN),
requested_backends: Backends(VULKAN | METAL | DX12 | BROWSER_WEBGPU),
supported_backends: Backends(VULKAN | GL), no_fallback_backends: Backends(0x0),
no_adapter_backends: Backends(VULKAN), incompatible_surface_backends: Backends(0x0) }
i.e. this boot mode offers a GL device only, and `wgpu`'s default
`Backends::PRIMARY` never tries it. Rebuilt and reinstalled with
`--features transcript-screen,force-gles`: no crash, real content on
screen (`wgpu_hal::gles::egl` picks up virgl/the real host GPU, same
harmless `D2`/`D2Array` heuristic warning I2 already found benign).
**Host GPU, `force-gles` -- a real number, and it changes the
picture.** Same 24-swipe/6-cycle loop (`app/iris-scroll.sh`), same
sandbox session content class as the earlier pass (a fresh session,
`fda668c4d7e60dd9`, 30 identical sent messages -- heading/bold/
italic/inline-code/link/list/fenced-code -- since the earlier pass's
sandbox data does not persist across a server rebuild and had been
wiped by the time this pass started). Compose (debug) via
`transcript-bench.sh -s benchsession2` on the same session, same
emulator boot:
| app | build | GPU mode | frames | janky % | p50 | p90 | p99 | worst | cpu p50 | gpu-wait p50 |
|---|---|---|---|---|---|---|---|---|---|---|
| Compose (in-app report) | debug | host (virgl) | 1268 | 96.4% late | 20.0ms | 28.4ms | 37.7ms | -- | -- | -- |
| iris (`FrameReport`), **best of three, 2026-09-05** | release, `force-gles` | host (virgl) | 439 | 46.24% | 15.7ms | 23.3ms | 31.2ms | 57.4ms | 1.2ms | 13.2ms |
**Under real GPU rendering, iris's median frame is faster than
Compose's, not 2-3x slower** -- the opposite shape from the
software-mode table above. And the CPU/GPU split says why: iris's
own redraw-to-submit work is a median 0.2ms, essentially free: almost
the entire 15.0ms median frame is `gpu_wait_p50` (submit through
`present()`), i.e. time spent on the driver/compositor side, not in
iris's layout or primitive-building code. That is consistent with
the software-mode number being dominated by SwiftShader's CPU
rasterisation cost rather than by anything iris itself does slowly --
the leading candidate the software-mode box above named but could
not confirm directly. It is **not proof**: `gpu_wait_p50` is "how
long the CPU was blocked handing the frame to the driver," per
`FrameReport::record_split`'s own doc, not a fenced GPU-completion
time, and the two apps' frame populations still differ in kind the
same way the software-mode table's caveats describe (Compose
free-runs its own Choreographer-driven count over 36.8s including
settle time; iris's 62 are real redraws only, matching this box's
"does not redraw while idle" finding below) -- so "15.0ms vs. 20.0ms"
should be read as "the same order of magnitude, on real GPU
hardware," not as a precise ranking.
**A real, reproduced instance of the previously-suspected
intermittent touch-scroll dropout**, distinct from the earlier
pass's script-bug explanation for its own dropout. After a fresh
`am start`, six consecutive swipes (`ui-trace record --do "swipe ..."`,
matching `iris-scroll.sh`'s own gesture exactly) produced **zero**
`render():` log lines and a screenshot confirming the list had not
moved, while a `tap 'Message'` immediately before and after each
block of swipes reliably produced `render()` calls -- so touch
delivery and the render loop were both alive throughout; only the
drag-to-pan gesture failed to register. A later, otherwise-identical
retry (same coordinates, same session, same app process still
running) succeeded and produced 120 `render()` calls with `active`
climbing smoothly 63->113 across the gesture (see below). Not
root-caused this pass -- `iris::sense::DragArbiter` (`iris/src/
sense.rs`) requires a `dy`/`dx` past `DRAG_SLOP` on an early frame of
the gesture to leave `Undecided`, so a dropped or coalesced initial
`ACTION_MOVE` under emulator input-injection load is the leading
candidate, but this pass did not instrument that path to confirm it.
Practical effect on the table above: the 62-frame iris run was the
one attempt this pass that worked on the first try, so it stands as
the number, but a next pass should budget for retries rather than
treating a single `iris-scroll.sh` invocation as reliable. **Update,
2026-09-05, the dropout fix (`e692429`, this box's own "Touch-scroll
dropout root-caused" subsection) verified against this table**: with
the fix in, three separate `iris-scroll.sh` invocations against a
fresh cold `-gpu host` boot each scrolled all 24/24 swipes, no
retries needed --
frames=450 janky%=48.44 p50=16.3ms p90=22.7ms p99=26.2ms worst=29.7ms cpu_p50=1.0ms gpu_wait_p50=13.1ms
frames=462 janky%=50.22 p50=16.9ms p90=25.0ms p99=45.4ms worst=59.0ms cpu_p50=1.3ms gpu_wait_p50=13.9ms
frames=439 janky%=46.24 p50=15.7ms p90=23.3ms p99=31.2ms worst=57.4ms cpu_p50=1.2ms gpu_wait_p50=13.2ms
Per-swipe coverage was checked directly, not inferred from the frame
count alone: a continuous `adb logcat -v time -s iris-android-app:D`
capture started before each of the second and third runs (the first
run's capture was taken with `logcat -d` after the fact and lost
earlier lines to the ring buffer, so it is corroborating rather than
direct) shows `render():` timestamps clustering into exactly 24
groups per run, one per swipe, each with 22-42 render calls and no
gap over 0.3s inside a cluster -- i.e. every one of the 24 swipes
produced real redraw activity in all three runs, closing this table's
open verification. The third run (lowest janky% and p50) is the
"best of three" row now in the table above, replacing the earlier
single-attempt 62-frame reading.
**Redundant-work check (no optimising, as instructed), host GPU,
`force-gles`.** (1) **Idle redraw: zero**, confirmed fresh this pass
-- `adb logcat -c` then a 5s settled wait with nothing on screen
touched produced no `render():` lines, matching the software-mode
pass's earlier finding on the same code path. (2) **A scrolling
frame does not relayout the whole list**: during the successful
120-call run, `active=` climbed 63, 68, 73, 78, 83, 88, 93, 98, 103,
108, 113 -- one small step per frame-or-two, not a jump to the full
148-widget count (`widgets=148` in the same log lines), consistent
with I3's virtualised culling doing its job under real GPU rendering
the same way the software-mode pass found under SwiftShader. Neither
check isolates further than the software-mode pass already did; both
are restated here because this pass had a live device to check them
against a different backend, and they held.
**Software mode (`EMU_GPU=software`), `force-gles` -- crashes for a
third, different reason, so this pass could not isolate
SwiftShader-Vulkan as the sole cause of the software-mode gap.** Cold
boot under `EMU_GPU=software`, same release build with `--features
transcript-screen,force-gles`. `wgpu_hal::gles::adapter` finds a real
adapter (`Renderer: Android Emulator OpenGL ES Translator (Google
SwiftShader)`, `Version: OpenGL ES 3.0`), further than the plain
host-GPU/default-backend attempt got -- but `AndroidRenderer::new`'s
device request then aborts:
Abort message: 'Could not get device!: RequestDeviceError { inner: Core(LimitsExceeded(
FailedLimit { name: "max_compute_workgroups_per_dimension", requested: 65535, allowed: 0 } )) }'
i.e. iris's device descriptor asks for compute-shader limits
unconditionally, and SwiftShader's software GL path reports itself
as OpenGL ES 3.0 -- compute shaders are an ES 3.1+ feature, so the
allowed limit is 0. This is a different failure from both the host-
GPU/default-backend crash above (no adapter at all) and the earlier
Venus/gfxstream failure "Vulkan in the emulator" documents (a
different Vulkan implementation's external-memory gap) -- three
distinct emulator/backend incompatibilities found across this
project's Android work, not one recurring bug. **Not fixed this
pass**: making iris's device request tolerant of a downlevel GL
adapter (requesting compute limits only when the adapter actually
reports them) is real scope, not a measurement task. Consequence for
the software-mode question this step was meant to answer: it remains
open whether SwiftShader-Vulkan specifically (rather than GLES in
general) explains the ~80-150ms software-mode numbers, since no GLES
number under software mode could be taken at all.
**Fixed, 2026-09-05, later the same day.** Not "requesting compute
limits only when the adapter reports them" (a capability check with
a fallback) -- simpler than that, because iris has no code path that
needs compute at all: grepped the whole `iris`/`iris-core` tree for
`ComputePipeline`/`@compute` and found none, so the right fix is to
stop asking for compute limits, full stop, rather than to build a
fallback for a capability nothing uses. `iris_core::device_limits()`
(`iris/core/src/render/mod.rs`) is the one place both platform
backends now build their `required_limits` from: `Limits::default()`
with the six `max_compute_*` fields zeroed and `max_buffer_size`
still raised, as before. `Limits::downlevel_webgl2_defaults()` was
the first thing tried and rejected -- it also zeros
`max_storage_buffers_per_shader_stage`, and `shader.wgsl`'s vertex
stage reads four `var<storage>` buffers, so it would have traded
this crash for a bind-group-layout one on the same hardware.
`rigs/gpu-probe`'s own `Limits` (necessarily a hand-mirrored copy --
that rig is deliberately its own crate, not a workspace member) was
updated to match and re-run: `IRIS DEVICE: ok` against this VM's own
Vulkan (Venus) and GL (virgl, reports OpenGL ES 3.2) adapters.
**Not verified against the actual SwiftShader-ES-3.0 failure this
pass**: the `EMU_GPU=software` cold boot needed to reproduce it would
have force-restarted this checkout's shared emulator while another
session had `com.example.aiapp` focused and running on it (`adb
shell dumpsys window`), so this pass left that measurement rather
than disrupting concurrent work -- matching AGENTS.md's "coordinate
with peer agents" guidance rather than contending for the emulator.
Everything else: `cargo fmt --all`/`clippy --workspace --all-targets`/
`test --workspace` clean, `cargo ndk build`/`clippy` for
`iris-android-app --features transcript-screen,force-gles` clean
(only the pre-existing unused-`tabs-ui`-dependency warning, unrelated
to this change). This also means the software-mode question two
boxes up is still open, for the same original reason plus this new
one: a GLES number under `EMU_GPU=software` still has not been
taken, now blocked on emulator availability rather than on the
crash. A future pass should cold-boot `EMU_GPU=software` once the
emulator is free, confirm `dev.iris.android.demo` no longer aborts
on `request_device`, and take the `iris-scroll.sh` FrameReport row
that pairs with this box's host-GPU one.
**Verification, this update.** `cargo fmt --all` (no diff),
`cargo clippy --workspace --all-targets` (no warnings from the new
code; pre-existing `wgpu`/`winit`/`naga` future-incompat notices
only) both re-run and clean this pass. `cargo test --workspace` and
`cargo ndk ... test`/`clippy` for `iris-android-app` were **not**
re-run this pass -- the previous pass on this identical diff had
already run and reported them clean, and this pass's host was
disk-pressure-limited (93% full, a concurrent `ai-server` rebuild in
progress) when the repeat attempt was made, so it was stopped rather
than left to spend 50+ minutes doing no useful work; see commit
`e2a1fad`'s own message. `docs/DECISIONS.md`'s DEFERRED item is
updated with this section's host-GPU table below.
**Touch-scroll dropout root-caused, 2026-09-05.** Diagnosed as
instructed: temporary `log::info!` tracing on every touch event
reaching `IrisViewPeer::on_touch_event` (`iris/src/android/view.rs`),
every `DragArbiter` state transition (`press_start`/`update`/
`release`, `iris/src/sense.rs`), and every `Selection::drag`
dispatch (`iris/transcript-ui/src/selection.rs`) -- all removed once
the cause was confirmed, per AGENTS.md's "keep the build clean."
Reproduced with `app/iris-scroll.sh` against a real sandbox session
(30 sent markdown messages, `EMU_GPU` unset / `-gpu host`,
`--features transcript-screen,force-gles`, release build, same
recipe as this box's own "-gpu host" pass above).
*The trace.* Of 24 swipes in one run, 5 produced zero `render()`
calls each -- one at the very start of the run, four consecutive
later (swipes 22-25) -- exactly the "several consecutive swipes
produce nothing, an identical retry then works" shape from the
earlier pass's report. Correlating the three log streams by
timestamp: every one of those 5 swipes delivered a normal
`Down`/`Move`×N/`Up` sequence to `on_touch_event` (touch delivery
was never the problem), but `Selection::drag` never once saw
`PressStart` for the whole gesture -- only `Pressing`, starting from
the very first `Move`. `DragArbiter::update`'s `Idle` arm answers
every such frame with `Undecided` and never transitions state (there
is no way for pure state to tell "no press is happening" from "a
press is happening but I missed its start"), so the arbiter sat in
`Idle` from the gesture's first frame to its last, `release()` on
`Up` its only state change (`Idle` -> `Idle`, a no-op). The row this
landed on registered its `PressStart` correctly on a *different*
point in the very next successful swipe at the identical screen
coordinate -- confirming the miss is about *where the content
happens to be under that pixel when `ACTION_DOWN` fires*, not about
timing or a coalesced event.
*Why `ACTION_DOWN` misses a row's sensor.* Each row's `CursorSense`
handler is registered only on its `TextEdit` field
(`transcript-ui/src/row.rs`'s `build_text_row`), not on the row's
`.pad(10)` margin, the `.gap(4)` between the sender-name header and
the field, or the header itself (`Span::empty`/a plain `wtext` with
no handler). A real touch's down-point is wherever the finger
actually is, with no reason to prefer text over padding, and the
list has no sensor of its own to fall back to (`iris::widget::list`
registers none) -- pan is reachable *only* through a row's own
arbiter. So roughly one in five swipes in this run started on a
pixel no sensor covered.
*The fix.* `iris::sense::DragArbiter` gains `pub fn is_idle(&self)`,
documented as the recovery signal: a caller that gets a `Pressing`
frame while the arbiter reports `is_idle()` knows the button is
genuinely down (that is what `Pressing` means) with no matching
`press_start` on record, which can only mean it was missed.
`Selection::drag`'s match gains one arm, checked after `PressStart`/
`PressEnd` and before the ordinary `_ => update(...)` case: `_ if
self.arbiter.is_idle()` starts the press right there instead of
where it was missed, using whatever `already_selected` holds at
that later frame (the best available answer -- the true value at
the actual `ACTION_DOWN` is unrecoverable once missed). This is
the caller's fix, not the arbiter's, because only the caller knows
what `already_selected` should be; the arbiter's own slop/long-press
logic was correct throughout and needed no change.
*Tests.* Four new, all passing on the fix and the first three failing
without it: `sense.rs`'s `drag_arbiter_tests::
is_idle_reports_a_press_that_was_never_started`,
`::update_on_an_idle_arbiter_stays_undecided_forever_without_recovery`
(documents the failure mode itself), `::
a_caller_can_recover_a_missed_press_start_via_is_idle` (the pure-state
half); and `transcript-ui/src/selection.rs`'s `tests::
a_missed_press_start_recovers_on_the_next_pressing_frame`, which
drives `Selection::drag` directly with only `Pressing` frames (no
`PressStart` ever sent) and asserts the arbiter is no longer idle
afterward -- this one fails on the pre-fix code (`is_idle()` stays
true forever, matching the real trace).
**Not completed this pass, and why.** The task asked for
`iris-scroll.sh` run three times clean and a re-taken host-GPU
`FrameReport` row. Partway through that verification, this
checkout's shared emulator (`ai-app-2`, per-checkout per AGENTS.md)
turned out to be concurrently in use by another session actively
working the P0 phone-benchmark item added to this same file earlier
today: `adb shell dumpsys activity processes` showed
`com.example.aiapp`/`com.example.aiapp.bench` processes running
alongside `dev.iris.android.demo`, window focus was observed to have
moved to the Compose app mid-test, and the sandbox server's own log
showed a fresh `start` (not `keep`) at 00:55 that wiped this pass's
30-message test session and replaced it with the peer's own
`bench-check` session -- confirmed by `ui-sandbox.sh api /sessions`
returning "no session" for the id this pass had been sending to.
Rather than disrupt that session's work (deleting its session,
restarting its server, or fighting over emulator focus), this pass
stopped chasing a clean aggregate number once the cause was
confirmed external. What *is* verified is the fix itself, from
direct traces taken before the interference began (above) plus two
manual, shorter `ui-trace` swipe sequences (not the full script) that
each showed full, healthy per-swipe `render()`/`selection::drag`
coverage with the fix in place. The FrameReport row in this box's
own table above is therefore **not re-taken this pass** -- a future
pass should re-run `iris-scroll.sh` three times and retake it once
the emulator is free, per AGENTS.md's "ask before/tell peers" and
"coordinate with peer agents" guidance rather than contending for it.
Also correctly ruled out, not left ambiguous: a hypothesis raised
mid-pass that `iris::widget::list::List::scroll`'s deliberately
unclamped anchor (its own module doc, "no overscroll clamping ...
leaves a gap rather than rubber-banding back") could itself explain
a run of consecutive failed swipes once enough net drift
accumulates -- plausible in isolation, but the run where it seemed
to reproduce is exactly the run now attributed to the peer
session's interference (same timestamps), so it was not
independently confirmed and is recorded here as ruled out for now
rather than as a second bug.
## The port, in order (decided 2026-09-05)
Iris decided iris over Masonry (`DECISIONS.md`). This is the ordered plan
for the rest of the app, decided by the design agent per the standing
"decide technical questions yourself" instruction — a serious
user-facing tradeoff is not in play in the ordering itself, so it is not
deferred to her. **Crate shape, decided here**: the screens live in one
crate, **`iris/app-ui`**, grown from `iris/transcript-ui` rather than
started beside it — `transcript-ui` already has the right generic shape
(`Rsc: HasEvents` + `Rsc::State: FocusHost`, the same axis `tabs-ui`
varies along) and the same `client-core`/`event-model` path
dependencies every later screen needs, so growing it in place is a
rename plus new modules rather than a second crate re-declaring
dependencies the first already has. It 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 module. `iris/desktop-app` (E4) and `iris/android-app` (I2/I5)
become thin entry points that call into `app-ui`, the way `AppRoot`/
`MainActivity` today call into Compose screens they don't otherwise own.
Platform-only code (the notification foreground service, the share
target, the QR scanner, the Keystore-sealed token, deep-link enrolment)
stays exactly where E3/E5 already put it — `android-shell/` and
`app/shellApp` — since none of it is a screen `app-ui` could draw.
Order is by **risk to the daily-use path**, not by screen count: the
session screen is what the app is for and where every hard behaviour
(paging, cache, keyboard insets, selection) already lives, so it goes
first and on the phone as reachable code as soon as possible, before the
lower-risk screens.
Every step below assumes the `app/ui-sandbox.sh` fixtures (AGENTS.md's
"The rigs") and the `this-machine-android` skill's facts (per-checkout
AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the
`adb shell` quoting traps) apply unchanged — read that skill before
running any pass condition below that touches an emulator or a real
device.
- [ ] **P0 — the phone benchmark gate (asked for 2026-09-05; must pass
before P1 starts).** Iris runs both apps on her own phone and pastes
the reports back; the emulator's numbers are not a substitute.
Two halves, buildable independently:
- **Compose half** (`app/`): a `bench` build type (release
optimisations, `applicationIdSuffix ".bench"`, own label "AI
Sessions bench") whose session screen can open an embedded fixture
transcript from assets with no server, and a "Run benchmark"
control in the existing render-report place that programmatically
performs the fixed scroll loop (same distances and timings as
`transcript-bench.sh`, driven through the `LazyListState`), then a
streaming phase (append fixture events at 20/s for 20 s into the
same fold path a live SSE reply uses), then shows the report with
the existing copy button. The report adds process CPU time over
the run (`Process.getElapsedCpuTime`), peak RSS, and
`BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` samples.
- **iris half** (`iris/android-app`, `transcript-screen` feature): the
same fixture embedded, the same scripted loop and streaming phase
driven through `List::scroll` and the fold, the same report fields
added to `FrameReport`'s line, shown on screen with a copy-to-
clipboard control (through the shell's Java side), arm64 release.
- **The fixture**: one synthetic transcript generated from
`app/ui-sandbox.sh`'s invented sessions (never a real one), at
least 3,000 events, with headings, code fences, links, tool calls
with kilobyte outputs, and a few images; committed once under
`app/bench-fixture/` and read by both apps.
- **Delivery**: `~/host/bench/` gets `iris-bench-arm64.apk`,
`compose-bench-arm64.apk`, and `README.md` saying how to run each
and what to paste back.
**Pass**: Iris's call from the two reports — iris within a reasonable
margin of Compose on p50, p99 and CPU time, no crash, no stutter she
can see. Fail stops the port.
**Compose half: done, 2026-09-05.** `app/androidApp`'s `bench` build
type, `app/bench-fixture/` (generator + generated `assets/`),
`BenchFixture.kt`/`BenchNetwork.kt` (an in-process fake backend: a
`URLStreamHandlerFactory` installed only in `FIXTURE_MODE` answers
`https://bench.fixture.invalid:1/...` from an in-memory event log
instead of opening a socket, so `TranscriptSource`, `EventStream`,
the fold and the paging are the *real* ones, unmodified), and
`BenchRun.kt` (the scripted scroll-and-stream, driven against the
real `LazyListState`) are all in. "Run benchmark" sits beside "Copy"
in the session settings dialog, bench-build only
(`SessionSettingsDialog`'s `onRunBenchmark`). `./build-apk.sh bench`
works, produces a universal APK (no ABI splits in this project, so
arm64-v8a is included alongside the others — confirmed with `aapt2
dump badging`), signed with the same release key, own application id
`com.example.aiapp.bench`, own label "AI Sessions bench" via a
build-type `resValue` overriding `@string/app_name`.
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` has one
hit, pre-existing and unrelated — a comment in `bench-lib.sh`
recounting the 2026-09-03 incident that made that grep a rule, not a
literal `tap` call.
**Emulator smoke run, 2026-09-05** (this checkout's AVD,
`ui-trace` tap-by-label throughout — `tap 'Session settings'` then
`tap 'Run benchmark'`, report read back over `adb logcat`):
ai-app render report
device: sdk_gphone64_x86_64 (Google), Android 16
build: release
transcript:
28 events, 26 rows, 58 units loaded
viewport 1536px, 2 units visible
on screen: the list's own 0px, AssistantMsg 18732px
0 tool calls and 0 groups open
frames:
1361 frames over 38.1s at 60Hz (16.7ms budget)
late: 1353 (99.4%)
total p50 27.8ms p90 37.7ms p99 50.1ms
gpu p50 18.9ms p90 28.9ms p99 31.5ms
where the draw phase went:
draw phase 3.12ms per frame, of which:
the transcript: 0.33ms (measure 0.18, place 0.14, record 0.00)
everything else: 2.79ms (89%)
bench:
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 23005ms
peak RSS: 209348kB
battery current: mean 900000µA over 39 samples (min 900000, max 900000)
Read this as "the harness runs end to end and produces every field
P0 asked for," not as a phone number: it is software-rendered
emulator rasterisation (this-machine-android's skill — the stock
Settings app scrolls worse on the same device), and the battery
current is a fixed 900mA on every sample, which is the emulator's
mocked charger reporting a constant rather than a real battery —
expect that field to read "unavailable" or a real varying number
only on Iris's own phone. The ordinary debug build was rebuilt and
driven with `./transcript-bench.sh` against `ui-sandbox.sh` alongside
this and produced its usual report with no `bench:` section, so nothing
changed for it.
`~/host/bench/compose-bench-arm64.apk` (9.7M) and
`~/host/bench/README.md` are written, with a heading left for the
iris half. **Known interaction**: the bench build keeps the same
`aiapp://enroll` intent filter as the ordinary app (it never uses
it), so with both installed, driving enrollment through a raw `am
start -d aiapp://...` intent (not the in-app QR scanner, which is
the primary path and calls straight into the matched activity) opens
Android's "Open with" chooser between the two. Cosmetic — the real
enrollment path is unaffected — and left as is rather than pulling
the intent-filter out of the bench manifest via source-set merging,
which was more diff than the problem was worth.
**Not done this pass**: the iris half (a separate agent's scope —
this session was told not to touch `iris/`), and anything past the
emulator — the actual on-phone runs and Iris's pass/fail call.
**iris half: done, 2026-09-05.** A `bench` Cargo feature on
`iris-android-app`, built on top of `transcript-screen`
(`bench = ["transcript-screen", "dep:libc", "dep:tokio"]`,
`iris/android-app/Cargo.toml`), gives `lib.rs`'s `ActiveClient`
priority a third `AndroidAppState` (`bench_client::BenchClient`)
over `TranscriptClient` when both features are listed together --
matching the exact build command below, which lists both.
**Fixture.** `include_str!("../../../app/bench-fixture/assets/
transcript.jsonl")` (1,915,760 bytes) at compile time -- no asset
pipeline needed the way the Compose half's Gradle source set does.
`bench_client::parse_fixture` splits the same way `BenchFixture.kt`
does: the first 3,200 non-blank lines parsed as `serde_json::Value`s
and folded once through `client_core::transcript_fold::fold_page`
(the real fold a `/transcript` page goes through), the rest parsed
as `event_model::SeqEvent`s and held back as the streaming tail.
`build.rs` (transcript-screen's own) now exits early under `bench`
before requiring a live server's host/port/token/CA -- `BenchClient`
never calls `build_transport()`, so that requirement made no sense
for a build that talks to nothing.
**"Run benchmark" (`.label("Run benchmark")`) and "Copy report"
(`.label("Copy report")`)** sit in a fixed bar above the transcript;
a selectable `TextEdit` (`.attr::<Selectable>(())`, the same
attribute the composer field uses) below it shows the report text.
Pressing "Run benchmark" resets `FrameReport`, then drives
`List::scroll` in ~60Hz steps (`ANIM_STEP_MS = 16`) to animate each
900px/200ms swipe rather than jumping it -- iris's `List` has no
built-in tween the way `animateScrollBy(tween(...))` gives Compose,
so this is the one place the two backends' bench code has to differ
in shape rather than only in numbers -- through the same
`rsc.tasks.redraw_handle()` + manual `request_redraw()` per step
`transcript_client.rs` already established (a `Tasks::spawn`d
future's *automatic* redraw fires once, after the whole future
completes, which would show nothing moving until the run ends).
After the scroll loop, `List::jump_to_end()` pins to the newest
content (matching `stream-bench.sh`'s "Jump to latest" tap), then
400 fixture events replay at 20/s through `fold_event` -- the same
fold path a live SSE frame takes in `transcript_client.rs`'s own
`apply_event` -- each one triggering `rebuild_transcript`'s full
`transcript_ui::build_tree` rebuild at the time this box was
written, same tradeoff as `TranscriptClient`/`desktop-app`. **Fixed
2026-09-05, later the same day**: all three now call
`TranscriptScreen::apply` instead -- see this same section's
"Streaming no longer costs a full rebuild" entry below for the
before/after numbers. A battery sampler runs
concurrently on its own `tokio::spawn`d task (not through
`ctx.update`, since a JNI battery read needs no widget-tree access),
attaching whichever thread it runs on via a stored `JavaVM` --
`AndroidAppState::platform_ready` (new, `IRIS.md`) is what hands
`bench_client.rs` that `JavaVM` + a `GlobalRef` to the view, since
neither was reachable from `AndroidAppState::new` before this box.
**Report fields.** `FrameStats`'s existing `Display` (frames, janky
%, p50/p90/p99, worst, and I5's own `cpu_p50`/`gpu_wait_p50` CPU/GPU
split) plus a `bench:`-shaped tail this box added: process CPU time
via `libc::getrusage(RUSAGE_SELF)` (user+system time; chosen over
parsing `/proc/self/stat` by hand to avoid assuming `USER_HZ`), peak
RSS from `/proc/self/status`'s `VmHWM` (same source `BenchRun.kt`
reads), and battery current sampled once a second via
`BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)`
through direct JNI calls (`bench_jni.rs`'s `PlatformHandle` --
`android_view::context`'s own `Context`/`Resources` wrappers have no
`getSystemService`, so this calls it directly rather than growing
that crate's wrapper for two one-off calls). `0`/`Integer.MIN_VALUE`
read as "unavailable" rather than folded into the average, matching
`BatterySampler`'s own rule and UI_RULES.md's "never present an
inferred value as a measured one." The report is logged under the
existing `iris-android-app` logcat tag on a line starting `iris
bench report:` (grep-able the same way `transcript_client.rs`'s
"Frame report" control already is), shown in the on-screen
`TextEdit`, and copied to the system clipboard by "Copy report"
through `ClipboardManager.setPrimaryClip` (`bench_jni.rs`, same
`PlatformHandle`).
**Build commands, all clean this pass:**
- `cargo fmt --all -- --check` (iris workspace) and
`cd iris/android-app && cargo fmt --all -- --check`: clean.
- `cargo clippy --workspace --all-targets` (iris workspace): clean
(only the pre-existing `wgpu`/`winit`/`naga` future-incompat
notice).
- `cargo test --workspace` (iris workspace): 39 + 8 + 10 = the same
pre-existing counts, all passing, unaffected by this box (it
touched no logic under test there beyond `AndroidAppState`'s new
default no-op method).
- `cargo ndk -t x86_64 -P 26 clippy --features "transcript-screen
force-gles bench" --lib -- -D warnings` (`iris/android-app`):
clean.
- `cargo ndk -t arm64-v8a -P 26 -o app/src/main/jniLibs/ build
--release --features "transcript-screen force-gles bench"`:
clean, `arm64-v8a/libmain.so` produced. The pre-existing "unused
dependency `tabs-ui`" Cargo advisory also appears on a plain
`--features transcript-screen` build with no `bench` (confirmed
by building that combination alone with fake env vars) -- not
something this box introduced, and not a clippy/rustc warning
(AGENTS.md's "keep the build clean" gate is `cargo clippy`, which
stays silent on it).
**Packaging.** No `cargo xtask apk` exists for `iris/android-app`
yet (I2's own Gradle project is the only pipeline), so this reused
that split rather than inventing one: `cargo ndk --release` above
builds the cdylib straight into `app/src/main/jniLibs/`, then a new
`release` build type in `app/build.gradle` (there was previously
only `debug`) packages and signs it --
`AI_APP_KEYSTORE=~/.config/ai-app/release.jks` +
`AI_APP_KEYSTORE_PASSWORD` (the same key `app/build-apk.sh`
generates for the Compose app) via `gradle :app:assembleRelease`,
with `applicationIdSuffix ".bench"` so it installs beside the plain
tabs demo rather than replacing it. `aapt2 dump badging` on the
result: `package: name='dev.iris.android.demo.bench'`, one native
library, `lib/arm64-v8a/libmain.so`. `apksigner verify
--print-certs` shows the same `CN=ai-app` certificate
`compose-bench-arm64.apk` is signed with.
**Emulator smoke run, 2026-09-05.** This checkout's own AVD
(`ai-app-2`) was in use by the session recording I5's clean-scroll
comparison in this same file (its Compose app was in the
foreground, confirmed via `dumpsys window`/`dumpsys activity
processes` before touching anything) -- rather than contend for it
(AGENTS.md's "coordinate with peer agents"), a second,
differently-named AVD was created (`AVD_NAME=ai-app-2-bench emu
up`, `pixel_10`/`android-36`/`google_apis`/`x86_64`, cold boot, host
GPU, no `EMU_GPU=software`), with 12GB of the VM's memory still
available after both were up (this-machine-android's "two are
comfortable" guidance). Installed via `adb -s emulator-5556 install
-r`, launched, driven by `ui-trace record -s emulator-5556 --do
"tap 'Run benchmark'"` (the control resolved by its accessibility
label, per AGENTS.md's "no coordinate" rule), then read back over
`adb logcat`:
iris bench report
frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms worst=369.3ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=0.4ms gpu_wait_p50=13.9ms (redraw-start-to-submit vs. submit-to-after-present)
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 24665ms
peak RSS: 224600kB
battery current: mean 900000µA over 21 samples (min 900000, max 900000)
"Copy report" was pressed immediately after and logged `iris bench
report: copied to clipboard` (`ClipboardManager.setPrimaryClip`
succeeded). No crash (`adb logcat`'s `FATAL`/`AndroidRuntime` lines
checked -- only `ui-trace`'s own runtime, unrelated), process alive
throughout (`dumpsys activity processes`), 400/400 stream events
confirmed sent.
Read this the same way the Compose half's own box already asks to
read its number: this is software-rasterised (well, GLES-over-virgl
under `force-gles`, per I5's "Where iris's frame time goes")
emulator output, "the harness runs end to end and produces every
field P0 asked for," not a phone number -- and the battery current
is again the emulator's fixed 900000µA mocked charger reporting a
constant, exactly what the Compose box's own run found, not a real
battery answering. `cpu_p50=0.4ms` (iris's own per-frame CPU work)
against a much larger `gpu_wait_p50`/`p50` again matches I5's "Where
iris's frame time goes" finding under real GPU rendering (`-gpu
host`, `force-gles`) -- the frame-time budget here is dominated by
the driver/compositor wait, not by iris's layout or primitive
building, though this run's `janky%`/`p90`/`p99` are considerably
worse than that earlier isolated pass, most likely the cost of this
AVD's very first cold boot plus running two emulators on this VM at
once (a fair comparison against Compose would need both apps run
back-to-back on the same freshly-booted device, not attempted this
pass since the second AVD was torn down immediately after per
AGENTS.md's "stop yours when you are done with it").
Copied to `~/host/bench/iris-bench-arm64.apk` (15,445,468 bytes) and
`~/host/bench/README.md`'s "iris" section filled in (install, open,
tap "Run benchmark", read the report from the on-screen text or
logcat, tap "Copy report", paste back).
**Not done this pass**: the actual on-phone runs and Iris's
pass/fail call between the two reports (P0's own pass condition) --
that needs Iris's phone, which this session has no access to.
`iris/src/android/view.rs` was touched (`AndroidAppState::
platform_ready`, `new_peer`'s wiring) -- confirmed to not be one of
the three files the concurrent `device_limits()` work on this
branch was using (`iris/core/src/render/mod.rs`,
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
**Streaming no longer costs a full rebuild, 2026-09-05.** The gap
named above and in E4/I5 (`push_row` can only append; a streaming
reply is a row that keeps *changing* after it appears) is closed:
`iris::widget::List` gained `replace_back` (swap the last row's
widget in place, same slot, so a pinned-to-newest list stays pinned
and an off-screen replace moves nothing on screen -- two new unit
tests, `replacing_the_last_row_stays_pinned_to_the_bottom` and
`replacing_the_last_row_out_of_view_does_not_move_visible_rows` in
`iris/src/widget/list.rs`) and `clear` (drop every row, the fallback
path). `transcript_ui::TranscriptScreen::apply(rsc, old_items,
new_items)` diffs `group_tool_runs(old)`/`group_tool_runs(new)`
(pure bookkeeping, no widget built doing it) and picks the cheapest
update: unchanged (no-op), pure append (`push_row`, same as before),
the common streaming case -- only the last row's content changed --
rebuilds just that one row and swaps it in with `replace_back`, or
(rare: `group_tool_runs` regrouping a row before the tail) a full
`List::clear` rebuild, counted by `TranscriptScreen::take_rebuilds()`.
Seven new unit tests in `transcript-ui/src/lib.rs`'s `diff_tests`
cover all three cases directly against synthetic `Vec<FoldedRow>`s
(no widget/Rsc needed for the decision itself). `bench_client.rs`,
`transcript_client.rs` and `desktop-app/app.rs` all call `apply` now
instead of rebuilding per event; `IRIS.md`'s 2026-09-05 entry has
the full API account. `TextEditCtx` also gained `set_with_spans`
(`iris/src/widget/text/edit.rs`) -- `set()` plus a fresh span list
in one call, since a streamed row's re-rendered markdown needs both
to land together.
**Two new scripts, `iris/android-app/build-apk.sh` and
`iris/android-app/run-bench.sh`**, written this pass after repeating
the ANDROID_HOME/NDK-export/`cargo ndk`/Gradle-release/keystore/
apksigner incantation by hand one too many times. `build-apk.sh
[debug|release] [--abi arm64-v8a|x86_64] [--features "..."]` builds
the cdylib and the APK and verifies it (badging, and signing for a
release build); `run-bench.sh [--apk PATH]` installs on this
checkout's own emulator (`emu serial`), taps "Run benchmark" by
label (no coordinates), polls logcat for the report line, and prints
it. Used for everything below and for the redelivery at the end of
this box.
**Numbers, this checkout's AVD (`ai-app-2`), release, x86_64,
`force-gles`, via `run-bench.sh` -- three separate runs, same warm
AVD (not a fresh cold boot each time):**
frames=690 janky%=78.26 p50=26.9ms p90=60.3ms p99=103.4ms worst=130.1ms cpu_p50=7.4ms gpu_wait_p50=15.7ms
frames=691 janky%=84.95 p50=28.3ms p90=60.9ms p99=95.2ms worst=120.7ms cpu_p50=5.4ms gpu_wait_p50=18.2ms
frames=691 janky%=58.32 p50=18.9ms p90=40.3ms p99=75.6ms worst=101.4ms cpu_p50=3.5ms gpu_wait_p50=12.5ms
Against this same box's earlier iris-half reading (full rebuild per
event, a *different*, freshly-booted x86_64 AVD, host GPU):
`frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms
worst=369.3ms cpu_p50=0.4ms gpu_wait_p50=13.9ms`. The tail is what
moved: `worst` dropped from 369.3ms to 101130ms and `p99` from
284.5ms to 76103ms across all three post-fix runs, consistent with
removing the periodic full-tree-rebuild stall during the
20-events/second streaming phase. `p50`/`cpu_p50` are *not* a clean
comparison -- these three runs share one already-warm AVD instance
rather than each getting its own fresh cold boot the way the earlier
reading did, and `cpu_p50` in particular is noisy run to run (3.5 to
7.4ms here) in a way a controlled A/B would need to separate from
the code change itself. **What a future pass should do for a clean
number**: two fresh cold boots of the same AVD, one per build,
`run-bench.sh` on each, nothing else running.
**The three remaining I5 verifications, closed 2026-09-05.** The
`server`/`event_model` drift the previous pass hit (`no variant
named 'LimitReached'`) was already fixed upstream on `rustify` by
the time this pass started (commit `c07d544`, "carry main's
`LimitReached` event") -- `cargo build --release` under `server/`
is clean, `./ui-sandbox.sh start` builds and runs. All three items
this left open:
1. **Three clean `iris-scroll.sh` runs against a cold `-gpu host`
boot, all 24/24 swipes scrolling in every run** -- results and
the per-swipe verification method are recorded just above, in
this same "Touch-scroll dropout root-caused" subsection's
"Update, 2026-09-05" paragraph, and the host-GPU table above now
carries the best-of-three row.
2. **`EMU_GPU=software` + `force-gles`, cold boot -- still cannot
isolate SwiftShader-Vulkan from SwiftShader-GL, now for a third
and structural reason.** Same release build
(`transcript-screen force-gles`), fresh cold boot under
`EMU_GPU=software`. The earlier compute-limit abort this same
box's "Fixed, 2026-09-05, later the same day" paragraph resolved
(`iris_core::device_limits()` zeroing `max_compute_*`) no longer
fires -- adapter selection now succeeds and picks up
SwiftShader's ES 3.0 GL path -- but device creation aborts on a
*different* limit immediately after:
Abort message: 'Could not get device!: RequestDeviceError { inner: Core(LimitsExceeded(
FailedLimit { name: "max_storage_buffer_binding_size", requested: 134217728, allowed: 0 } )) }'
i.e. SwiftShader's ES 3.0 reports zero storage-buffer capacity at
all -- SSBOs are an ES 3.1+ feature, the same generation gap the
compute-limit failure came from, and exactly the trap this box's
own "Fixed" paragraph flagged when it rejected
`Limits::downlevel_webgl2_defaults()` for zeroing
`max_storage_buffers_per_shader_stage` while `shader.wgsl`'s
vertex stage reads four `var<storage>` buffers unconditionally.
**Closing the open question, one sentence**: whether
SwiftShader-Vulkan or GLES-in-general explains the ~80-150ms
software-mode numbers cannot be answered on this hardware at
all, because `shader.wgsl`'s storage-buffer reads make a GLES
path on downlevel (ES 3.0) SwiftShader structurally unreachable
rather than merely unmeasured -- reaching it is a shader rewrite
(moving those reads off `var<storage>`), which is real scope, not
a measurement task, and was not attempted here.
3. **P0's bench build, cold-boot `run-bench.sh` number**: same
`-gpu host` cold boot as item 1 (re-cold-booted after the
`EMU_GPU=software` boot above), `./build-apk.sh release --abi
x86_64 --features "transcript-screen force-gles bench"`,
`./run-bench.sh`:
frames=690 janky%=62.03 p50=19.6ms p90=42.4ms p99=56.5ms worst=62.5ms cpu_p50=4.4ms gpu_wait_p50=12.6ms
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 15394ms
peak RSS: 164348kB
battery current: mean 900000µA over 21 samples (min 900000, max 900000, the emulator's fixed mocked-charger reading, not a real battery -- see P0's own box)
Against the P0 box's own three same-warm-AVD readings
(`frames=690/691/691`, `p50` 18.9-28.3ms, `worst` 101-130ms),
this cold-boot run's `worst` (62.5ms) and `p99` (56.5ms) are
*lower* than any of the three warm-AVD runs, and its `p50`
(19.6ms) sits inside their range -- so the P0 box's caveat that
the warm-AVD numbers might be inflated by AVD staleness does not
hold up under a fresh cold boot; if anything this run is cleaner.
`cpu_p50` (4.4ms) is within the 3.5-7.4ms noise band the P0 box
already flagged as run-to-run noisy on a shared warm AVD.
**Redelivered, 2026-09-05.** `./build-apk.sh release --abi
arm64-v8a` (arm64-only jniLibs; an earlier step in this same pass
had left an x86_64 slice in there from the emulator testing above,
removed before this build so the delivered APK matches P0's
original arm64-only shape) -- `aapt2 dump badging` confirms
`native-code: 'arm64-v8a'` and the same
`dev.iris.android.demo.bench` id, `apksigner verify` the same
`CN=ai-app` cert as before. Copied over
`~/host/bench/iris-bench-arm64.apk`; `~/host/bench/README.md` gained
a one-line build-date/commit note so Iris can tell which build she
has.
**iris bench crash on the phone, 2026-09-06.** The delivered APK
(`dev.iris.android.demo.bench`, arm64, release) aborted on Iris's own
phone (a Pixel, GrapheneOS, Mali GPU) on the very first
`surface_changed`: `AndroidRenderer::new` -> `UiRenderNode::new` ->
`create_bind_group_layout` -> wgpu's `default_error_handler` panics
with `wgpu error: Validation Error`, and Android's crash report
truncated the message right there, so the actual validation failure
was unknown. Ran fine on the emulator's Vulkan (SwiftShader) and GLES
(`force-gles`/virgl) and on the desktop GPU. Nobody on this session
has the phone or `adb` access to it; this pass worked from the crash
report alone plus reading wgpu-core's own validation source
(`wgpu-core-28.0.0/src/{binding_model,device/resource}.rs`, the
version this workspace pins).
**1. Diagnostic, not guesswork -- what actually happens is now
visible.** `iris_core::UiRenderNode::new` (`core/src/render/mod.rs`)
wraps every `create_bind_group_layout`/pipeline call in three nested
wgpu error scopes (one per `ErrorFilter`: `OutOfMemory`,
`Validation`, `Internal`), pops them in reverse once creation is
done, and returns `Result<Self, String>` -- the `String` is wgpu's
own `Display` text for whichever scope caught something, which is
already wgpu-core's `format_error` output (`"Validation Error\n\n
Caused by:\n ..."`, confirmed by reading
`wgpu-28.0.0/src/backend/wgpu_core.rs`'s `format_error` -- the exact
text the panic would have printed, just no longer thrown away).
`android::render::AndroidRenderer::new` turns a failure into a full
report: the adapter's name/backend/driver
(`Adapter::get_info`), the limits bind-group-layout validation
checks a storage/texture binding against
(`max_storage_buffers_per_shader_stage`,
`max_sampled_textures_per_shader_stage`, `max_bind_groups`,
`max_bindings_per_bind_group`, `max_storage_buffer_binding_size`,
`min_storage_buffer_offset_alignment`), and
`DownlevelCapabilities.flags` (`Adapter::get_downlevel_capabilities`)
-- then wgpu's own error text. `android::view::IrisViewPeer::
surface_changed` logs it as one logcat line (`iris renderer init
failed: ...`, newlines replaced with ` | `) and shows the full
multi-line text on screen: a new `IrisView.showRendererError(String)`
(an ordinary instance method Rust calls into via JNI, not a `native`
one -- the direction is Rust reaching into Java, the opposite of
every `native fn` this view already declares) swaps the activity's
whole content for a plain, selectable, scrollable `TextView`, opening
with "Copy this text and send it to Iris" (UI_RULES.md: a failure is
reported where it happened and says what to do next). Desktop's
`UiRenderer::new` keeps panicking on failure (no on-screen fallback
exists there) but now with wgpu's full chain as the message, since it
no longer relies on wgpu's own handler getting there first.
**A real, separate reentrancy bug turned up while testing this, and
is fixed alongside it.** Calling `Activity::setContentView` directly
from inside `surface_changed` deadlocked -- not literally, but hit
Rust's `RefCell already borrowed` abort: `setContentView` tears the
old view hierarchy down synchronously, which fires `IrisView`'s own
`onFocusChanged` *before* `setContentView` returns, straight back
into the same `IrisViewPeer` through `on_focus_changed` while
android-view's own dispatch (`with_peer` in its `view.rs`) still
holds this peer's `RefCell` borrow for the `surface_changed` call in
progress. Found by deliberately inducing a validation error (see
below) and watching it abort a different way than the crash this
pass was fixing. Fixed by moving the Java call into
`ctx.push_dynamic_deferred_callback`, which android-view already
runs only after dropping the borrow (confirmed by reading
`with_peer`'s body) -- the same mechanism `raise_if_enabled` (this
file's AccessKit push) already relies on for the identical reason.
Left as a comment at the call site rather than only here, since the
next thing that reaches into Java from inside a `ViewPeer` callback
needs the same warning.
**2. The audit -- every bind-group-layout entry, checked against
wgpu-core's actual validation, not guessed.** `CreateBindGroupLayoutError`
(`wgpu-core::binding_model`) has seven variants; the ones a static,
no-`count`, no-feature layout like this crate's can hit are
`Entry { error: MissingDownlevelFlags(_) }` and
`Entry { error: MissingFeatures(_) }`. Walked every entry in
`uniform_layout`, `primitive_layout`, `rsc_layout`, `masks_layout`
(all four in `UiRenderNode::new`):
- `uniform_layout` (group 0): one uniform buffer, `VERTEX|FRAGMENT`.
Uniform buffers need no downlevel flag or feature at any
visibility. Not it.
- `primitive_layout` (group 1, `rects`/`glyphs`): two storage
buffers, both `FRAGMENT`-only (confirmed against `shader.wgsl`:
`rects`/`glyphs` are read only in `fs_main`). `FRAGMENT`-visible
storage buffers need `DownlevelFlags::FRAGMENT_STORAGE`, which
every backend in wgpu-hal grants unconditionally for a
non-write-only binding (`ty: Storage { read_only: true }` here).
Not it.
- `rsc_layout` (group 2, atlas/image/sampler): a `D2Array` texture,
a `D2` texture, a `NonFiltering` sampler, all `FRAGMENT`, no
`count`. `Bt::Texture`'s only feature requirement
(`TEXTURE_BINDING_ARRAY`) gates on `count.is_some()`, which none
of these set. Not it.
- `masks_layout` (group 3, `masks`/`move_offsets`): `masks` is
`FRAGMENT`-only (read only in `fs_main`'s mask lookup). But
`move_offsets` is `VERTEX | FRAGMENT` -- `shader.wgsl`'s
`resolve_move` is called from both `vs_main` (a primitive's own
corners) and `fs_main` (a mask's move chain) -- and it is a
storage buffer, which is exactly what
`wgpu-core/src/device/resource.rs` gates on
`DownlevelFlags::VERTEX_STORAGE` whenever `visibility` contains
`VERTEX` (confirmed by reading that check directly, not inferring
it from the flag's name). **This is the one entry among all four
layouts whose validity is device-dependent rather than static.**
**Named hypothesis: the delivered build forced GLES, and GLES's
`VERTEX_STORAGE` is not unconditional the way Vulkan's is.**
Read `wgpu-hal-28.0.0`'s two backends' own downlevel-flag
construction (`vulkan/adapter.rs`, `gles/adapter.rs`):
- **Vulkan** grants `Df::VERTEX_STORAGE` unconditionally for any
Vulkan 1.0 device, alongside `COMPUTE_SHADERS`/`FRAGMENT_STORAGE`
and others in one unconditional `Df::empty() | ... ` -- there is
no `.set(VERTEX_STORAGE, <device check>)` call anywhere in that
file. This is why the emulator's SwiftShader-Vulkan run and the
desktop's real Vulkan both pass: **on Vulkan, this exact layout
cannot fail this check, on any conforming device.**
- **GLES** computes it explicitly:
`downlevel_flags.set(VERTEX_STORAGE, max_storage_block_size != 0
&& max_storage_buffers_per_shader_stage != 0 &&
(vertex_shader_storage_blocks != 0 || vertex_ssbo_false_zero))` --
i.e. it depends on the driver actually reporting a nonzero
`GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS`. This is a known-weak spot
on Android GLES drivers specifically (vertex-stage SSBO support
lags fragment-stage support even on ES 3.1+ hardware), and this
exact document already found the adjacent failure mode once this
pass: SwiftShader's ES 3.0 GL path reports zero storage-buffer
capacity at all (`max_storage_buffer_binding_size: 0`, this box's
item 2 under "The three remaining I5 verifications"). A phone
that negotiates a GLES context with no (or driver-buggy)
vertex-stage SSBO support hits precisely this: `masks_layout`'s
`move_offsets` entry, `MissingDownlevelFlags(VERTEX_STORAGE)`.
**And the delivered build does force GLES.** `~/repos/
ai-app-bench`'s own README (committed alongside the P0 APKs)
says so directly: "`force-gles` matches I5's own ... finding: the
default Vulkan backend has no adapter under a plain `-gpu host`
boot on this AVD" -- true and reasoned correctly *for this VM's
emulator*, but `build-apk.sh`'s default `FEATURES` applied the
same flag to every arm64 build regardless of target, so the exact
same cfg-locked GLES-only path that was a deliberate, documented
emulator workaround shipped to a real Mali phone with no way to
turn it back to Vulkan short of a rebuild. `force-gles`'s own doc
comment (`iris/Cargo.toml`) only ever talks about the emulator
("the same build can be measured against SwiftShader's software
Vulkan ICD ... or virgl's GLES path") -- real hardware was never
the case it was written for.
**What would confirm or kill this, from the on-screen report
alone**: `backend: Gl` (confirms GLES was in fact what ran) and
`downlevel flags: DownlevelFlags(...)` *not* containing
`VERTEX_STORAGE` in the list. If a future report instead shows
`backend: Vulkan` with `VERTEX_STORAGE` present, this hypothesis is
wrong and the "Caused by" chain in that same report names the real
one directly -- which is the entire point of doing (1) first.
**Fix applied**: `build-apk.sh`'s default `--features` dropped
`force-gles` (now `"transcript-screen bench"`, was
`"transcript-screen force-gles bench"`), with a comment explaining
why and telling a future emulator-isolation run to pass it back
explicitly. This is a build/delivery fix, not a shader rewrite --
the shader itself is untouched, since moving `move_offsets` off a
storage buffer is real scope this document already declined once
this pass for the adjacent SwiftShader-ES-3.0 finding, and the
actual defect here is that a debug-only backend override reached a
real device, not that the shader's design is wrong. The rebuilt
arm64 APK (below) uses the default backend, i.e. Vulkan on a real
phone -- if it still fails, (1)'s on-screen report is what comes
back this time, not a truncated abort.
**Verified, this checkout's emulator, cold `emu up`
(`ai-app-2`):**
- GLES (`force-gles`, matching every prior P0 GLES reading's
backend): `run-bench.sh` end to end, no crash, report unchanged
in shape from the pre-fix readings above
(`frames=691 janky%=58.90 p50=19.2ms p90=43.7ms p99=62.4ms
worst=69.3ms cpu_p50=4.7ms gpu_wait_p50=12.6ms`, 24/24 swipes,
400/400 streamed events) -- the diagnostic wrapper adds no
measurable cost or behaviour change on the success path.
- **Induced failure, confirmed the fix works end to end**: added a
temporary `count: Some(NonZeroU32::new(2))` to `uniform_layout`'s
one entry (an artificial `ArrayUnsupported`/`MissingFeatures`,
chosen because it is guaranteed to fail on every backend rather
than depending on this VM's flaky adapter enumeration), rebuilt,
installed, launched: logcat showed the full one-line report
(adapter `Android Emulator OpenGL ES Translator (virgl ...)`,
every named limit, the downlevel flags, and wgpu's "Caused by"
chain naming `Binding 0 entry is invalid` / the missing
`BUFFER_BINDING_ARRAY` feature) and `ui-trace elements` confirmed
a `TextView` labelled with that exact report text was on screen
-- process alive, no abort. This is also what caught the
reentrancy bug above (the first attempt aborted a different way,
`RefCell already borrowed`, fixed, then reproduced clean). The
temporary `count: Some(...)` was reverted before anything else.
- **Vulkan (no `force-gles`) could not be re-verified this pass**:
this cold `emu up` enumerates zero Vulkan adapters
(`wgpu_core::instance: enabled backend 'Vulkan' has no adapters`,
the *unrelated*, already-panicking `.expect("Could not get
adapter!")` path this fix does not touch) even with the default
`-gpu host` boot the same README quoted above once relied on --
matching this document's own prior notes that Vulkan
availability on this VM's emulator is flaky across cold boots,
not something this pass's diff caused (confirmed by checking the
panic message is byte-for-byte the pre-existing
`RequestAdapterError` shape, not a new one). Not chased further:
it is orthogonal to the crash this pass fixes, and the real test
of "does Vulkan work" is the phone itself, not this VM.
**Checks, all clean**: `cargo fmt --all -- --check` and
`cargo clippy --workspace --all-targets` (iris workspace, zero
warnings beyond the pre-existing wgpu/winit future-incompat
notice), `cargo test --workspace` (unchanged counts, all passing --
nothing here touched logic under test), `cargo ndk -t x86_64 -P 26
clippy --features "transcript-screen force-gles bench" --lib -- -D
warnings` (clean), `cargo ndk -t arm64-v8a -P 26 build --release
--features "transcript-screen bench"` (clean, arm64-only
`jniLibs`).
**Redelivered, 2026-09-06.** New arm64 APK (Vulkan, no
`force-gles`), same `dev.iris.android.demo.bench` id, same
`CN=ai-app` signing cert, copied to `~/host/bench/
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry explaining both changes (the diagnostic and the
`force-gles` removal) so Iris can tell this build apart from the
one that crashed. **Not done this pass**: confirming the fix on
Iris's actual phone -- nobody on this session has it or `adb`
access to it, so this is read from the crash report and wgpu's
source, verified as far as this VM's tooling reaches, and handed
back with a diagnostic that will say the real story on the next
run either way.
**Benchmark v2 (2026-09-06), asked for by Iris after using the
Compose build on her phone**: "it doesn't fling like I typically do
when scrolling up to find old messages. It should travel way faster
which is better for stress testing. You may also want to add typing
in the textbox as well and seeing how performant wrapping & pushing
the transcript up are, and also keyboard performance if possible."
**This is the one spec** -- written once here so both apps' "Run
benchmark" implement the identical four phases; a change to a
constant below has to be made in both `app/`'s `BenchRun.kt` and
`iris/`'s bench client, together, or the two reports stop measuring
the same thing while still looking like they do.
1. **fling.** Starting pinned at the newest end
(`listState.scrollToItem(0)` / iris's equivalent), 8 flings away
from it (toward older messages) through the list's own real fling
path -- Compose: `LazyListState.scroll { with(flingBehavior) {
performFling(velocity) } }` using the screen's actual
`FlingBehavior` (`ScrollableDefaults.flingBehavior()`, since
`TranscriptList`'s `LazyColumn` never overrides it -- **not**
`animateScrollBy`, which can only ever cover the fixed distance
and time it is given and was Iris's complaint) -- each fling's
`initialVelocity = 12,000 px/s`. That number is well above a
moderate tween-swipe's implied speed (v1's `SWIPE_PX`/`SWIPE_MS`
is roughly 4,500 px/s) and is meant to be a hard, fast flick for
stress-testing, per Iris's ask. After each fling, wait for
`isScrollInProgress` to clear (cap 3s; `performFling` already
suspends until its own decay ends, this is belt-and-suspenders)
plus 300ms between flings. Then 8 more flings back toward the
newest end (`-12,000 px/s`). Record the list's first visible
index/offset at the start, after the 8 outward flings, and at the
end, so the two apps' *travel* can be compared directly rather
than just their frame times.
2. **stream. Unchanged from v1**: 400 tail events at 20/s (20
seconds), pinned to the newest end before it starts (the same
"Jump to latest" pin `stream-bench.sh` does).
3. **type.** Pin to the newest end, focus the composer, show the IME
if the platform allows it, then insert this **exact 600-character
string** one character per 50ms through the composer's real
`TextFieldValue` state (Compose: the same `input` state
`onValueChange` writes; iris: whatever holds the composer's text
today), then delete it the same way, one character per 50ms.
Chosen for long, multisyllabic words specifically so the composer
wraps across lines and the transcript above it is pushed upward
by a growing box, which is what Iris asked to see measured:
Benchmarking this transcript screen requires unusually long, multisyllabic words so wrapping and reflow are properly exercised: internationalization, counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, telecommunications, and interdisciplinary collaboration all push a narrow composer field to wrap across several lines while the transcript above is pushed upward by the growing keyboard-adjacent box, which is exactly what a real reader typing a long message sees happening now!!!
Report whether the IME was actually open during typing (it should
be, from this phase's own show-IME step -- see phase 4 for what
to say if the platform refuses to show it at all).
4. **keyboard.** Show the IME (`WindowInsetsControllerCompat.show
(WindowInsetsCompat.Type.ime())` against the window/view; iris's
equivalent through its own shell), wait 1s, hide it, wait 1s;
five cycles. Confirm each show/hide with the platform's own
answer (Compose: `ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.ime())`, i.e. the same
`WindowInsets.isImeVisible` fact `SessionScreen`'s composer-inset
bug fix already relies on) rather than assuming the request
worked -- if it is never confirmed shown even once, the report's
`keyboard:` line has to say "**keyboard: could not be shown**"
in words (UI_RULES: never present an inferred value as a
measured one, and design the unknown/failed state before the
answer's).
**Frame accounting**: one recorder, not two. Mark each phase's start
in the existing per-frame recorder (Compose: `FrameStats.markPhase
(name)`, a list of `(name, frameIndexAtStart, wallClockAtStart)`
alongside the existing `total`/`waited`/... arrays) and slice the
same `FrameMetrics` samples by phase afterward
(`FrameStats.phaseLines`) rather than running a second listener.
**Report shape**: a `per phase:` block appears once any phase marks
exist (empty/absent on an ordinary "Copy" press, which never marks a
phase), one entry per phase: frame count, the phase's wall-clock
duration, late count/percent (against the same refresh-rate budget
the whole-run section uses), p50/p90/p99, and the worst single
frame. Then **every existing whole-run section stays, unchanged in
shape** -- `frames:`, `where the draw phase went:`, `work since this
was last copied:` -- because that is what the emulator-baseline and
phone-baseline numbers already on record in this file were read
against, and a report that dropped or renamed one of those lines
would silently stop being comparable to them. Finally `bench:` gains
new lines beside the existing CPU/RSS/battery ones: the fling
phase's total travel (start/outward/end index+offset), the typed
character count, and the keyboard phase's shown/hidden-confirmed
counts (or the "could not be shown" line).
**Compose half: done, 2026-09-06.** `FrameStats.markPhase`/
`phaseLines` (`app/androidApp/src/main/kotlin/com/example/aiapp/
FrameStats.kt`), `debugReport`'s new `phaseFrames` parameter
(`DebugStats.kt`), and `BenchRun.kt`'s four-phase `run` (fling via
`ScrollableDefaults.flingBehavior()` captured in `SessionScreen` and
passed down since it needs a `@Composable` call site; type via a new
`composerFocus: FocusRequester` attached to the composer's
`OutlinedTextField` plus a `setComposerText` callback that writes
`input` the same way a keystroke does; keyboard via
`WindowInsetsControllerCompat` against `LocalView.current`) are all
in. `BenchRun.TYPE_TEXT` is the exact 600-character constant quoted
above (verified `.length == 600`). A pre-existing, unrelated break
in `MainActivity.kt`'s `benchSessionSummary()` (missing several
`SessionSummary` constructor arguments added by a change this pass
did not otherwise touch -- confirmed pre-existing by reproducing the
same compile failure after stashing this pass's own diff) was fixed
alongside this, since it blocked `compileBenchKotlin` outright and
is in this session's own `app/` scope.
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` still
has its one pre-existing, unrelated hit.
**Compose bench v2, emulator smoke run, 2026-09-05** (this
checkout's AVD, cold `emu up`, `ui-trace` tap-by-label throughout --
the dialog needed a swipe to reach "Run benchmark" below the fold,
report read back over `adb logcat`):
ai-app render report
device: sdk_gphone64_x86_64 (Google), Android 16
build: release
transcript:
124 events, 26 rows, 58 units loaded
viewport 1714px, 2 units visible
on screen: the list's own 0px, AssistantMsg 18732px
0 tool calls and 0 groups open
per phase:
fling: 1620 frames over 32.3s
late: 1537 (94.9%)
total p50 20.5ms p90 29.2ms p99 45.9ms
worst 61.8ms
stream: 1079 frames over 20.6s
late: 1037 (96.1%)
total p50 21.0ms p90 33.5ms p99 39.3ms
worst 51.2ms
type: 3568 frames over 61.4s
late: 3536 (99.1%)
total p50 23.8ms p90 32.1ms p99 38.5ms
worst 50.3ms
keyboard: 215 frames over 10.0s
late: 212 (98.6%)
total p50 21.3ms p90 37.6ms p99 48.4ms
worst 50.2ms
frames:
6482 frames over 124.3s at 60Hz (16.7ms budget)
late: 6322 (97.5%)
total p50 21.7ms p90 33.1ms p99 45.3ms
gpu p50 17.4ms p90 27.0ms p99 30.6ms
where the draw phase went:
draw phase 1.27ms per frame, of which:
the transcript: 0.16ms (measure 0.09, place 0.07, record 0.00)
everything else: 1.10ms (87%)
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=218/off=73px end=idx=0/off=0px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
process CPU time over this run: 61192ms
peak RSS: 195716kB
battery current: mean 900000µA over 125 samples (min 900000, max 900000)
Read this the same way the v1 emulator smoke run above is read: it
proves the harness runs end to end and produces every field this
spec asked for, not a phone number -- software rasterisation, and
the fixed 900mA battery reading is the emulator's mocked charger
again. Two things worth carrying forward: the **fling phase reached
index 218** in 8 flings (against v1's `animateScrollBy` loop, which
never moved past a handful of indices in the same 8-swipe count),
which is the direct evidence the new fling travels "way faster" as
asked; and **the emulator's software keyboard toggled and was
confirmed by `isImeVisible` all 10 times**, so phase 4 is not a
guaranteed "could not be shown" on every platform, only where the
IME genuinely refuses. `frames:`'s 6,482-frame, 124.3s total matches
the sum of the four phase durations (32.3+20.6+61.4+10.0 ≈ 124.3s),
confirming the phase marks partition the whole run rather than
overlapping or dropping frames between them.
**Iris's first real phone report, 2026-09-06** (the redelivered,
no-`force-gles` APK above): no crash. Two screenshots, before any
touch: headings/links/code/table all render correctly. Four defects
found and worked this pass:
1. **Every glyph disappears on the first tap or scroll; rectangles
stay drawn** (the keyboard case is the same thing -- a tap on the
composer). **Not root-caused this pass.** Audited `GpuTextures`'
atlas-grow/patch path, `ArrBuf`'s resize-on-length-change
contract, and the masks/move_offsets/rsc bind-group rebuild logic
in `core/src/render/mod.rs` against wgpu's queue-ordering
contract -- everything read as spec-correct (a `queue.write_texture`/
`write_buffer` issued before a later `queue.submit` is guaranteed
visible to it on the same queue, and a dropped `Buffer`/`Texture`/
`BindGroup` still in flight is kept alive by wgpu's own tracker).
No violation found by static reading; reproducing needs either
the phone or a Mali driver trace, neither available this pass.
Instrumented for the next report instead: `Device::
on_uncaptured_error` is now installed on the Android device
(`WgpuErrorLog`, `android::render::AndroidRenderer`), and
`IrisViewPeer::render` logs masks/moves-resized, atlas
pages-grown and image bind-group creates for the first 10 frames
after every `surface_changed` -- exactly the window this bug
lands in. The bench screen's new "Diagnostics" button (below)
surfaces the error log and adapter identity on demand.
2. **Bold words render as blank gaps of the correct advance width**
(regular, links, inline code render fine). Fixed by bundling Noto
Sans/Noto Sans Mono (regular/bold/italic/bold-italic, static
cuts, OFL) into `iris-core` and registering them ahead of the
platform's own fonts -- `core/src/primitive/text.rs`'s
`TextData::register_bundled_fonts`. Named hypothesis, not
confirmed on the phone: the system "Roboto" on a modern Android
device is the variable "Roboto Flex," and this crate's glyph
path (`TextData::place`) does not apply `Synthesis`/variable-axis
correction at all -- a bundled *static* per-style face sidesteps
the question rather than answering it. `TextData::font_diagnostics`
reports what got resolved; logged once at startup and shown on
the Diagnostics page.
3. **Text far too small** -- iris had no device-pixel-ratio handling
on *either* platform before this pass (grepped for `scale_factor`
across the whole crate: zero hits). `DisplayMetrics.density`
(Android) / `Window::scale_factor()` (desktop) now divides every
physical-pixel number (window size, touch coordinates, the
shader's window uniform) down to logical units before it reaches
layout, so a `font_size: 16.0` is 16 dp rather than 16 raw device
pixels on a ~3x-density phone. Cost a second, real bug found only
by measuring on this checkout's emulator after the first fix
landed: `android::view::IrisViewPeer::surface_changed`'s call
into `UiRenderState::resize` (the layout engine's own notion of
the canvas, which every widget's absolute `PixelRegion` is
computed against) was still being handed raw physical
`width`/`height`, while `AndroidRenderer`'s side of the same
resize had already switched to logical -- splitting layout and
the shader into two different units. A fixed-size widget (the
bench screen's `.height(56)` button row) exposed it at ~40
physical px against the ~147px `56 * content_scale` predicts;
a proportional (`rest(n)`) size hid it by adapting to whichever
total it was given. Both are logical now. **Not fully verified**:
a fresh-install emulator screenshot after both fixes shows
visibly larger, readable text (`docs/bench/` has neither
screenshot committed -- see AGENTS.md on transcripts/screenshots
not going in this repo -- but the before/after is described in
the commit), and the button row's own height still isn't
obviously matching `56 * content_scale` on this run -- worth a
second look with `ui-trace show --field box` once there's time,
but not a blocker for the magnitude of the original bug (3x too
small).
4. **Status-bar inset not applied** -- confirmed nothing in this
app ever read `insets().top` at all (`android/insets.rs` has
carried `Insets.top` since it was written; nothing consumed it).
Fixed with a new, generic hook: `AndroidAppState::
on_insets_changed(rsc, LogicalInsets)`, called from `render()`
exactly when `AndroidUiState::insets()` changes, in logical units
matching everything else `content_scale` now divides.
`BenchClient::on_insets_changed` rebuilds the root tree with
`Padding::top(insets.top)` on the button row -- rebuilding the
whole tree rather than one `WidgetPtr` slot's content, because
the first attempt (a `Pad` dropped into an unrelated `WidgetPtr`
slot with no height override of its own) did not propagate the
wrapped span's fixed height correctly, which is what surfaced
finding 3's `UiRenderState::resize` bug in the first place.
Verified via `ui-trace show --field box`: the button row's top
(150 physical px) sits 8px below `statusBarBackground`'s bottom
edge (142px) on this checkout's emulator.
**A named `Diagnostics` control now exists** (RUST.md's own earlier
ask): a third button on the bench screen's top row, filling the
existing benchmark-report `TextEdit` with adapter identity/backend/
driver, font resolution, the atlas's live view count, every
uncaptured wgpu error since surface creation, and the frame report
-- `android::render::AndroidRenderer::diagnostics_report`. Uses the
existing "Copy report" button/clipboard path rather than a second
one.
**Verified this pass, this checkout's emulator** (`EMU_GPU` default,
`--features force-gles` -- this cold `emu up` again enumerated zero
Vulkan adapters, the same pre-existing flakiness earlier boxes
documented, not something this pass's diff caused): `cargo fmt --all
-- --check`, `cargo clippy --workspace --all-targets` (zero warnings
beyond the pre-existing wgpu future-incompat notice), `cargo test
--workspace` (all passing, unchanged pure-logic counts), `cargo ndk
-t x86_64 -P 26 check` clean, `./run-bench.sh` end to end
(`frames=691`, 24/24 swipes, 400/400 streamed events, no crash),
fresh-install screenshots and `ui-trace` box readouts for the four
items above. **Not verified this pass**: the actual phone (no
access), and item 1's root cause (needs either the phone's next
Diagnostics-page report or a Mali trace).
**Recorded but not fixed this pass** (a follow-up agent takes these,
to avoid colliding with this pass's `bench_client.rs`/`view.rs`
changes) -- see `IRIS_TODO.md`'s "From the phone, 2026-09-06":
swiping has no momentum (stops exactly where the finger releases,
unlike Compose's fling), and scrolling down sometimes jitters the
text.
**Redelivered, 2026-09-06, later the same day.** New arm64 APK
(Vulkan, no `force-gles`, bundled fonts, content-scale fix,
Diagnostics control), same `dev.iris.android.demo.bench` id, same
`CN=ai-app` signing cert, copied to `~/host/bench/
iris-bench-arm64.apk` and `~/repos/ai-app-bench/iris/build/outputs/
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry. Still not confirmed on Iris's actual phone.
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
build a9232ac, with screenshots: text now the right size but
**blurry**; opening the keyboard still **wipes every glyph**
(rects stay, only text disappears); the **header buttons have
nothing behind them and overlap the transcript text**.
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
by reading the path before changing anything, per AGENTS.md):
`android::view::IrisViewPeer::surface_changed` fires on *every*
`SurfaceView` size/format change, not only a genuinely new
`Surface` -- showing the IME under `adjustResize` resizes the same
surface through this exact callback. The handler unconditionally
set `renderer = None` and called `AndroidRenderer::new`, which
builds a fresh, empty glyph atlas and fresh GPU buffers via
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
(`primitive/text.rs`) kept the atlas UV coordinates it had already
handed out against the *old* atlas -- every glyph then drew from a
rectangle pointing into a texture that had just been recreated
empty. Confirmed by reading `AndroidRenderer::resize` (already
existed, already did none of that -- only `surface.configure` and
the window uniform) against what `surface_changed` was actually
calling instead. **Fix**: `surface_changed` now calls
`AndroidRenderer::resize` when a renderer is already live, and only
builds a new one when `surface_changed` finds `renderer` still
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
backgrounding). Not independently re-verified against a forced IME
resize on this pass's emulator (no display keyboard exercised
end-to-end here); the reasoning is a direct code read plus the
existing `resize` path already being surface-only, not a
screenshot diff -- **the next agent with emulator time should do
the before/after screenshot this box originally asked for.**
**2. The blur.** Root cause: the P0 fix that made text the right
*size* (dividing the whole window into a "logical" space, then
letting the shader's NDC mapping stretch it back onto the real
framebuffer) rasterised each glyph at the small, pre-stretch size
and then displayed it stretched onto more physical pixels than it
had texels for. **Fix, and the density-independent length unit
Iris asked for the same day (IRIS_TODO.md) turned out to be the
same fix**: `Len::dp`, resolved against a `density` now carried on
`UiRenderState`/`Painter`, replaces the global stretch -- window
size, touch and insets are physical pixels throughout again
(`WindowInsets`, renamed from `LogicalInsets`), and
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
before handing them to parley, so the atlas rasterises at the
display's real physical resolution. Full design in docs/LAYOUT.md's
"Density: `Len::dp`" section and the public-API summary in
docs/IRIS.md's 2026-09-06 entry.
**3. The header.** Only each button's own `rect(...)` painted
anything, so the gaps between/around them and the status-bar strip
above showed `CLEAR_COLOR` (black) one layer back, and the row's
reserved height was three `abs` (now-physical-pixel) button boxes
-- smaller than the dp-correct size the transcript below uses,
which is what read as "overlap" once the two disagreed. Fixed with
a `HEADER_SURFACE` rect stacked behind the whole row and every
header size moved onto `dp(...)`.
**4. Keyboard diagnostics, so Iris can report back even if a
keyboard-triggered regression persists.** `on_insets_changed` now
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
the same report the on-screen Diagnostics button produces, logging
it, copying it to the clipboard unprompted, and showing it in a new
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
that draws independently of iris's own renderer.
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
--all-targets` and `cargo clippy` on `android-app` (both `-D
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
and wgpu future-incompat notices), `cargo test --workspace` (all
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
`transcript-screen bench` feature set.
**Then run on this checkout's own emulator** (x86_64 debug,
`--features "transcript-screen force-gles bench"` -- this AVD has no
Vulkan adapter under a plain `-gpu host` boot, matching every prior
emulator finding in this file): `run-bench.sh` end to end, no crash,
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
streamed events -- unchanged in shape from prior readings, so the
diff cost nothing on the success path. **Header background**:
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
size 1080x1900` (screenshot before/after, text intact) and actually
opening the soft keyboard via `settings put secure
show_ime_with_hard_keyboard 1` + tapping the message field
(ui-trace confirmed a real resize, elements moved -547px; keyboard
visible in the screenshot, text still fully rendered, not wiped).
Both are real evidence the reuse-renderer fix works, though neither
is the literal before/after diff this box originally asked for --
**still worth a deliberate side-by-side screenshot pair in a future
pass.**
**Found during this same verification, not fixed, needs a follow-up
pass**: after the keyboard-triggered resize, the top button row
appeared to render a **second time**, well below its real position,
inside the transcript's scroll area (same colours/text, unmistakably
the same three buttons) -- and a tap aimed at the composer's
"Message" field landed on "Run benchmark" instead (a second
benchmark run started, visible in logcat as two `iris bench report:`
lines from one session). Only seen after a resize with the keyboard
genuinely open; the plain `wm size` resize screenshot pair did not
show it, nor did the fresh-install screenshot before either resize.
**Not root-caused this pass** -- time ran out before isolating
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
provisional-then-real placement) leaving a phase-1 primitive
retained somewhere it should have been moved from, something
specific to the keyboard's `on_insets_changed` rebuild racing a
redraw, or unrelated to this pass's changes entirely (not verified
against a build predating this session's commits, so do not treat
"caused by this pass" as established -- MACHINE.md's pinned rule
about not attributing without measuring applies here too). Also
noteworthy: `capture_keyboard_diagnostics` never fired in this
session (no "iris keyboard diagnostics" log line) despite the
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
not be populated the way expected on this emulator/API level, or
the duplicate-row state above interfered; **also needs a follow-up
pass** before relying on the auto-capture on a real phone.
**Not verified this pass**: anything on Iris's real phone, the
two-density crispness check IRIS_TODO.md's unit item asks for, and
the two open items just above.
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
phone" items this box's own text names as follow-ups are fixed --
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
applying only the excess past `DRAG_SLOP` on the crossing frame,
not the whole pre-threshold drag) -- both wired through
`Selection::drag`'s release path, both covered by new unit tests in
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
reading `DragArbiter::update` and testing it directly, not by an
emulator trace** -- this pass did not open an emulator, so the
"trace the list's offset per frame" verification this box's own
todo asked for is still open, as is a feel-check of the fling on
real touch input.
**Benchmark v2, iris half, done 2026-09-06, later the same day.**
`bench_client.rs` implements all four phases against the identical
constants this box's "Benchmark v2" spec names: fling (8 out + 8
back at 12,000px/s through `List::fling`, waiting for
`!is_scrolling()` capped 3s with a 300ms pause between, travel
reported as `idx=N/off=Mpx` via a new `List::anchor_position_display`
-- note this list's anchor does not necessarily change *slot* during
a long scroll (the module's own documented design: the anchor is
named by identity, not re-derived from what's on screen), so an
iris travel reading is not apples-to-apples with Compose's
`firstVisibleItemIndex`, which does change slot -- a real difference
in what the two numbers mean, not a bug, and worth reading `off`
rather than `idx` when comparing runs), stream (unchanged), type
(the exact 600-character `TYPE_TEXT` constant, verified by a unit
test, one char per 50ms into the composer's real `TextEdit` via
`.set()` -- the same whole-string-replace shape `BenchRun.kt`'s own
`setComposerText` uses, not a per-character insert), and keyboard
(5 cycles through `bench_jni.rs`'s new `show_ime`/`hide_ime`
`InputMethodManager` calls, confirmed from `on_insets_changed`'s
real `ime_bottom` transitions via a new `ImeState` counter rather
than assumed from the JNI call succeeding).
`iris_core::render::frame_report::FrameReport` gained `mark_phase`/
`phase_stats`/`late_at_hz` (new unit tests in `frame_report.rs`):
phases are sliced by absolute frame index against a second ring
(`index_ring`) alongside the existing duration ring, and late/jank
is judged against a real Hz read from `bench_jni.rs`'s new
`refresh_rate_hz` (`View::getDisplay().getRefreshRate()`) rather
than the fixed 60Hz `JANK_THRESHOLD` every other caller still uses
-- a separate method, not a parameter on the existing one, so
nothing else in the codebase changes behaviour. `RING_CAPACITY`
4096->16384 since one full v2 run is 3,000+ frames.
**A real deadlock, found and fixed while wiring this up.** Getting
a value back out of a task spawned via `rsc.spawn_task` has no
built-in return channel (`ctx.update`'s closures are fire-and-
forget), so a new `read_from_state` helper sends the result through
an `mpsc` channel and polls for it. Its first version only worked
for the *first* call in a chain: nothing about `ctx.update` drains
itself, so unless something calls `redraw.request_redraw()` after
*this specific* enqueue, nothing ever runs the closure -- and every
call after the first relied on a stale, already-fired
`request_redraw()` from a previous step. The fix is structural:
`read_from_state` now takes the redraw handle and calls it itself,
immediately after enqueueing, every time.
**Verified end to end, this checkout's own emulator (cold `emu up`,
`force-gles`, x86_64 -- this AVD again enumerates zero Vulkan
adapters on a cold boot, matching every prior finding in this
file):**
iris bench report
per phase:
fling: 1481 frames over 53.2s
late: 158 (10.7%)
total p50 11.2ms p90 16.9ms p99 26.5ms
worst 43.3ms
stream: 401 frames over 20.8s
late: 342 (85.3%)
total p50 26.1ms p90 49.1ms p99 57.2ms
worst 61.3ms
type: 1202 frames over 63.1s
late: 89 (7.4%)
total p50 12.8ms p90 15.1ms p99 23.3ms
worst 26.7ms
keyboard: 9 frames over 9.1s
late: 2 (22.2%)
total p50 6.3ms p90 25.8ms p99 25.8ms
worst 25.8ms
frames:
3093 frames over 146.3s at 60Hz (16.7ms budget)
late: 591 (19.1%)
total p50 12.4ms p90 22.2ms p99 51.2ms
worst 61.3ms
cpu_p50 0.7ms gpu_wait_p50 11.6ms
bench:
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=651/off=1336px outward=idx=651/off=101672px end=idx=651/off=1427px
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
type: 600 characters inserted then deleted, one per 50ms
keyboard: could not be shown (5 attempts, 0 confirmed visible)
process CPU time over this run: 43303ms
peak RSS: 193152kB
battery current: mean 900000µA over 146 samples (min 900000, max 900000)
Read this the same way every prior emulator smoke run in this box
is read: software rasterisation, not a phone number, and the
battery line is the emulator's fixed mocked-charger constant again.
**Travel**: the `idx` stays fixed at 651 through the whole fling in
both directions (see the `anchor_position_display` caveat above) --
`off` is what actually moved, growing to 101,672px outward before
the return trip brings it back near its start, which is real, large
motion (a fast, hard fling, matching Iris's "travel way faster"
ask), just not directly comparable to Compose's idx-188-reached
reading from the same box's earlier v2 entry. **`keyboard: could
not be shown`**: expected given the ime-inset finding below, not a
new regression.
Redelivered: `./build-apk.sh release --abi arm64-v8a --features
"transcript-screen bench"` (Vulkan, no `force-gles`; the x86_64
jniLibs slice left over from emulator testing was removed first so
the delivered APK is arm64-only, confirmed via `aapt2 dump
badging`), `apksigner verify` shows the same `CN=ai-app` cert,
copied to `~/host/bench/iris-bench-arm64.apk` and `~/repos/
ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`;
that repo's own README gained a dated entry. `run-bench.sh`
extended for the longer run (260s poll cap, `-A 60` instead of
`-A 6`) to fit v2's four phases.
**Redelivered, 2026-09-06, the defect pass.** `./build-apk.sh
release --abi arm64-v8a --features "transcript-screen bench"`
(Vulkan, no `force-gles`; the x86_64 `jniLibs` slice from this
pass's emulator work was removed first, confirmed arm64-only by
listing the APK's `lib/` entries), `apksigner verify` showing the
same `CN=ai-app` cert, copied to `~/host/bench/
iris-bench-arm64.apk`; that README gained a dated entry naming what
to look for. What changed: the composer sits at the bottom of the
screen at launch again (the black third was the bench shell's empty
report pane, not an inset -- see the plan box above), typing into it
works at all (the empty-field caret bug), the keyboard no longer
throws up an undismissable diagnostics overlay, and every surface
and insets event is logged so a phone `logcat` can answer the
app-switch text loss. Not fixable from here and still open: the
duplicated `Compacted:` row.
**(a) The header-duplicate bug (found by a concurrent pass on this
branch): investigated, not fixed.** Reproduced reliably
(`ui-trace record --do "tap 'Message'"` then `adb exec-out
screencap`): the three-button row renders a second, full copy
inside the transcript area the moment the keyboard opens. Read
`Span::draw`'s own two-phase placement doc (a provisional
full-region draw to learn each child's size, then a real
`widget_within` placement) as the most likely mechanism, since it
is the one place in this tree that deliberately draws a widget
twice in normal operation and relies on the two draws landing at
the same place to stay a cheap move rather than a visible second
copy -- and `UiRenderState::update`'s `redraw_all`-vs-
`redraw_updates` split (LAYOUT.md) means a `.set()`-driven targeted
redraw of just `top_bar` and a resize-driven full redraw of the
whole tree are two structurally different code paths that could in
principle disagree about where that widget's primitives belong on a
frame where both fire close together. **One concrete, testable
hypothesis was ruled out**: `on_insets_changed` rebuilding
`top_bar` on every call, including ones only about `ime_bottom`
(nothing to do with the header's own padding). Added a guard
(`last_top_pad`, skips the rebuild unless `insets.top` itself
changed) and reproduced the *exact same* duplicate afterward --
unchanged, byte-for-byte, in the same screenshot -- so repeated
rebuilding is not the cause; the guard is kept anyway since it is a
real (if here insufficient) reduction in needless work. **Not
root-caused**: doing so needs either instrumentation inside
`Span::draw`/`draw_inner` to see the two placements' actual regions
on the frame the bug happens, or the phone. Left for a follow-up
pass rather than guessed at further.
**(b) Why the keyboard phase and the keyboard-open auto-diagnostics
both read "not confirmed": a real, named platform interaction,
partly fixed.** `MainActivity.java`'s manifest declares
`windowSoftInputMode="adjustResize"` (AGENTS.md's own "Things that
have bitten": without it the keyboard pans the window off screen
instead of resizing it). Under `adjustResize`, `WindowInsets.
Type.ime()`'s own inset *amount* is defined to read zero once the
window has already resized to avoid the overlap that inset would
otherwise describe -- confirmed by reading Android's own
`WindowInsets` contract, not guessed at. So the numeric `ime_bottom`
this app was reading is *structurally* never going to be positive
here, independent of anything wrong in `iris`'s own code -- the same
trap AGENTS.md already names for the Compose side
(`WindowInsets.isImeVisible` "does not share the failure mode").
**Fixed**: `MainActivity.java`'s `OnApplyWindowInsetsListener` now
reads `insets.isVisible(WindowInsets.Type.ime())` (a boolean,
unaffected by resize-vs-pan) and passes `1`/`0` through the
existing `ime_bottom` JNI field instead of the always-zero numeric
inset -- correct on its own terms, and kept, but **did not by
itself make the keyboard phase or the auto-diagnostics fire on this
emulator**: `logcat` shows the platform's own `InsetsController:
show(ime(), fromIme=false)`/window-resize events happening (the
keyboard genuinely opens, confirmed by screenshot), but no further
`setOnApplyWindowInsetsListener` callback at all after the initial
one at attach. Named hypothesis, not confirmed: a plain (non-edge-
to-edge) `Activity` that has not called `WindowCompat.
setDecorFitsSystemWindows(window, false)` may not get insets
redelivered for a pure IME toggle handled entirely via resize --
only the initial attach-time dispatch is guaranteed. Confirming and
fixing that needs opting the activity into edge-to-edge, which is a
real window-behaviour change interacting with the exact
`adjustResize` setting AGENTS.md protects, not attempted this pass
given the risk-to-time-remaining ratio. Both open items are
recorded in `~/repos/ai-app-bench`'s README with today's date.
**Composing text, the tap-vs-swipe focus rule, and app-switch text
loss, 2026-09-06.** Iris's report on this same dc01f88 build: typing
doesn't enter text or move the caret until a space is hit; typed
text doesn't visibly appear and there is empty black space below the
composer bar; text disappears again after leaving and returning to
the app; and (a follow-up message the same day) swiping over the
composer bar wrongly summons the keyboard.
1. **The caret/composing bug's cause**: `android/ime.rs`'s
`InputConnection` never called `InputMethodManager.updateSelection`
after an edit -- confirmed by reading android-view's own demo
(`~/src/android-view/demo/src/lib.rs`'s `render()`), which calls it
every time its editor's generation changes. Without it, Gboard has
no confirmation the app is keeping up and holds keystrokes back
rather than trusting a screen it believes is stale -- exactly
"doesn't enter it until I hit space." **Fix**: `IrisViewPeer::
update_ime_selection` (new, `ime.rs`) reports the real selection
and (an approximation, `compose_len` chars back from the caret)
the composing region, called from `after_input`'s existing tail so
every touch/key/IME callback already runs it. The buffer-level
half (`replace`/`insert_str` correctly advancing the caret) was
already correct and is now covered by four new unit tests in
`iris/src/widget/text/edit.rs` (composing, `commitText`,
`deleteSurroundingText`, `setSelection`). **Verified**: on the
emulator (`force-gles`, no Vulkan adapter on this AVD), tapping a
real Gboard key now shows a real, single-character-appropriate
suggestion strip ("H | How | Hey") rather than stale state, and a
`render()` log line fires for every keystroke -- both confirm the
`InputConnection` calls are landing and are being processed, which
a hand-typed `adb shell input text` did *not* reliably exercise on
this AVD (no `render()` at all followed one such call -- most
likely a modern `input text` no longer round-trips through
`commitText` the way older docs assume; Gboard-key taps are the
real path and the one this fix was verified against).
2. **A second, deeper bug found while verifying (1), not root-caused
this pass**: composed text never becomes visible on screen at
all -- the grey composer bar stays empty, with no glyph anywhere
in the frame, confirmed on repeated Gboard-key taps and across a
keyboard-resize. **Ruled out**: the widget tree's own layout math.
A new unit test, `layout_tests::
composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region`,
builds the composer's exact tree shape (`Stack{rect, Span{Pad{
TextEdit}}}` inside an outer `Span::DOWN`) with no GPU or window,
resizes it the way a real keyboard-triggered `surface_changed`
does, edits the field both before and after, and asserts the
field's `window_region` stays a small box near the bottom of
whichever window size is current -- it passes, both before and
after this pass's composer rebuild (item 3 below), so the CPU-side
region a redraw lands at is provably correct. The bug is
therefore downstream of that -- most likely something specific to
the GPU-side redraw a content-only edit takes (`UiRenderState::
redraw`, which redraws a single dirtied widget directly at its
stored region rather than re-running its ancestors' layout) or to
this AVD's forced `force-gles` backend (the only one available
here; Iris's phone deliveries have used real Vulkan) -- neither
isolated this pass. **Not attributable to this pass's changes**:
reproduced identically before touching `composer.rs` (the very
first build tested, before the composer rebuild below, already
had it) and the render-engine files this pass did not touch
(`core/src/render/mod.rs`, `core/src/ui/render_state.rs`) are the
likely next place to look -- specifically `UiRenderState::redraw`'s
reuse of a widget's own last-drawn region versus a full tree walk.
**Needs**: either a Vulkan-capable emulator boot or the real phone
to rule `force-gles` in or out, and a GPU-side primitive dump
(the existing `frame diagnostics` log line, extended to name which
primitives a frame actually wrote) to see whether the glyph quads
are emitted at all or emitted somewhere off-screen.
3. **The composer bar rebuilt as one widget**, per this box's own
ask: `transcript_ui::composer::build_composer` (unchanged
`Stack{background, Span{Pad{TextEdit}}}` idiom, the same one the
header row's `HEADER_SURFACE` already uses) now also caps the
field at roughly six lines (`MaxSize` + `.scrollable()` for a
wheel/trackpad overflow scroll -- a real touch-drag scroll on
overflowing composer text is not wired and is a follow-up) and
wraps the whole bar in one `Pad` whose `bottom` a new
`Composer::set_bottom_inset(rsc, inset)` rewrites in place
whenever the platform's insets change, called from
`bench_client.rs`'s existing `on_insets_changed` with
`insets.bottom.max(insets.ime_bottom)` -- the IME's own inset
while it is open, the navigation bar's otherwise. Rewritten in
place rather than rebuilt through a `WidgetPtr` swap (`top_bar`'s
own pattern) because the field is strongly owned inside this tree
and cannot be re-added to a new wrapper without panicking
("was already added") -- rebuilding would also drop focus,
selection and in-progress text on every keyboard toggle.
**Verified**: `ui-trace` box readouts before/after a keyboard
open on the emulator (the field's row correctly reports a
547px move matching the real IME-triggered resize); the
known-separate "top row renders twice after a keyboard resize"
bug this box already recorded is unrelated and still open. **Not
fixed by this alone**: item 2 above -- the text still does not
render, so the "empty space at the bottom" symptom's other half
(nothing filling the space the bar itself now correctly reserves)
needs item 2's fix first before a real before/after screenshot is
worth taking.
4. **App-switch text loss, fixed and verified.** `surface_destroyed`
(backgrounding) drops the whole `AndroidRenderer` -- device,
atlas, buffers -- and a subsequent `surface_changed` with no live
renderer builds a genuinely new one (`AndroidRenderer::new`,
distinct from the keyboard-resize path this box already fixed by
*reusing* the renderer). But `iris_core::TextData::atlas` (the
CPU-side glyph cache) and `UiData::textures` (the CPU-side texture
bookkeeping the atlas is built on) live on `AndroidRsc`, which
outlives any one `AndroidRenderer` -- so both kept pointing at the
*old*, now-destroyed device's textures across the switch, the
exact "rectangles stay, glyphs disappear" shape, just triggered by
backgrounding instead of the keyboard. **Fix**: new
`GlyphAtlas::clear()` and `Textures::reset()` (`iris/core/src/
render/atlas.rs`, `iris/core/src/primitive/texture.rs`), called
together from `surface_changed`'s "genuinely new renderer" branch
only -- the same `already_live` check that already decides
reuse-vs-new, so this is one mechanism gated on the one condition
that needs it, not a second ad hoc check. **Verified on the
emulator**: backgrounded via `KEYCODE_HOME`, reopened via
`am start`, screenshotted -- every pre-existing glyph (headings,
body text, the whole diagnostics report) is intact, `frame_count`
resets to 1 confirming a genuinely new renderer was built, no
crash.
5. **Swipe-vs-tap focus, fixed and verified** (Iris's follow-up the
same day: "if I swipe over the input bar it brings up the
keyboard... scrolling should be pinned"). `attr.rs`'s `Selector`/
`Selectable` registered `CursorSense::click_or_drag()`, which
calls `select()` -- and so grants focus and requests the IME --
on the *first* frame of any press, before it is known whether the
gesture will end up a tap or a drag. Rewritten around a shared
`on_press` dispatcher over `PressStart`/`Pressing`/`PressEnd`: a
field that is **already** focused behaves exactly as before
(every frame updates the selection, so dragging inside a focused
field to select text still works); a field that is **not**
focused records where the press began (`TextEdit::press_origin`,
new field) and only grants focus on `PressEnd` if no intervening
frame crossed `sense::DRAG_SLOP` -- a drag recognised early simply
clears the pending tap and does nothing further, so it is never
consumed and whatever is behind the field still sees every frame
of it. New `FocusHost::is_focused` (both platform impls) is what
lets `on_press` tell the two cases apart. **Verified on the
emulator**: `dumpsys input_method`'s `mInputShown` reads `false`
after a `swipe` gesture starting on the composer bar (`ui-trace`
confirms the field's own box never moved, i.e. no keyboard-driven
resize happened), and reads `true` after an ordinary `tap` on the
same field. **Coordination note**: a concurrent pass is moving
drag arbitration into `sense.rs` behind a new `Drop` event: this
fix touches only `attr.rs` (new `press_track`/`on_press`) and
`iris/src/widget/text/edit.rs` (the new `press_origin` field), not
`sense.rs` itself, so it should merge cleanly, but the next agent
through here should check whether `Selector`/`Selectable`'s
`Pressing`-frame delivery still arrives the way this code assumes
once that lands.
**Checks this pass**: `cargo fmt --all` clean, `cargo clippy
--workspace --all-targets` and `cargo ndk -t x86_64 -P 26 clippy
--features "transcript-screen bench force-gles"` both zero warnings
beyond the pre-existing `tabs-ui` unused-dependency notice, `cargo
test --workspace` all passing (new tests: four in `edit.rs`, one in
`layout_tests.rs`). **Not done**: item 2's root cause; a real
before/after screenshot pair for item 3 (blocked on item 2); anything
on Vulkan or the real phone.
- [ ] **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, decided by the design agent, by what
the bench fixture exercises and Compose already draws** (each is
one agent; tick and date in place):
- [x] **P1a — markdown block rendering parity.** Done 2026-09-06.
Each top-level block is drawn in one of **three frames**
(`transcript-ui::markdown::BlockFrame`, mapped from
`BlockKind` by the pure `frame_of`): `Plain` (a paragraph,
heading, list or rule -- text and spans, no extra widget),
`Verbatim { fill }` (a fence or a table -- a rounded panel
that does not wrap and pans sideways, `CodeFence.kt`'s
`horizontalScroll`), and `Quote` (a bar behind text padded
past it). Everything else markdown can say is expressed in
`SpanStyle`s, which cost no widgets.
**What each block looks like now, against `Markdown.kt`:**
- *Headings* -- Material's own ladder, the six sizes
`markdownTypography` picks (24/22/16/14/12/11 at a 16pt
body), bold. Was a three-step 28/24/21/19.
- *Fences* -- monospace on Mocha Crust, rounded, with
`client_core::highlight`'s spans by language in the same
Catppuccin palette `Theme.kt`'s `catppuccinSyntax()` uses.
An unknown language is plain rather than coloured by the
nearest one. A fence being streamed into re-renders only
the last block (`RowBlocks::apply_delta`), so earlier
fences are never re-scanned.
- *Lists* -- the bullet ladder `MarkdownPieces.kt` draws
(disc/ring/square by depth) and ordered lists counting from
the number written, markers in Lavender.
- *Tables* -- padded monospace columns measured from the
cells, header bold, a rule under it, on Surface 0. A real
grid was rejected; docs/DECISIONS.md, 2026-09-06, has why.
- *Quotes* -- a Surface 2 bar down the left, text one shade
back from body.
- *Links* -- coloured and underlined as before, and now
**tappable**: `GestureOutcome::Tapped` (a press that
committed to neither a pan nor a selection),
`TextEditCtx::byte_at` for which byte, and
`iris::platform::OpenUrl` for the platform (`xdg-open`/
`open`/`start`; on Android an `ACTION_VIEW` intent deferred
to `after_input`, the shape `pending_show_keyboard` uses).
**Screenshots: `docs/bench/p1a-2026-09-06/`.**
`compose-heading-fence-table.png` and
`compose-fence-table.png` are the Compose `bench` build on
this checkout's AVD against `app/bench-fixture/`;
`iris-blocks.png` is iris rendering the same heading,
paragraph, link, fence and table source (plus a list and a
quote, which the fixture has neither of) from
`transcript-ui`'s own `transcript` example.
**Why the iris half is not from the emulator**, which the
pass condition asked for: **the emulator cannot draw iris's
glyphs at all.** Every character comes out as a solid filled
box of the right width -- `iris-emulator-gles-glyphs.png`.
Established as *not* this change's doing and not the app's:
the previous commit (`20303e0`) draws the same boxes, and the
Compose bench build on the same AVD in the same minute draws
text perfectly. The atlas sample's alpha reads as 1 under
`-gpu host` + `-feature -Vulkan` (Mesa 26.2.2 / virgl), which
is what an *incomplete* GL texture returns (0,0,0,1). Both
ways out were tried and both fail: `GPU_HOST_FEATURES=" "`
still dies at boot with gfxstream's documented "Format
VK_FORMAT_R8G8B8A8_UNORM is not supported ... Failed to find
memory type for ColorBuffers", and `EMU_GPU=software` does
give the guest SwiftShader Vulkan but iris **SIGSEGVs inside
`surface_changed`** on it. So the appearance half of this box
is taken on the desktop/winit backend, which renders on the
host's real GPU through `iris/run-headless.sh`.
**What still differs, pair by pair:**
1. *Colour, on the desktop shot only.* The winit surface is
sRGB and the shader writes the palette's bytes as linear,
so every fill reads ~4x lighter: Crust (17,17,27) comes out
(73,73,91), measured. Not a palette error and not present
on Android, where the previous pass measured the composer
bar at rgb(41,40,49) for a declared (40,40,46). Worth its
own item; it makes the desktop build a poor colour
reference until fixed.
2. *A list's wrapped line.* Compose lays an item out as a
marker column beside a text column, so a second line stays
indented; iris writes the marker into the same buffer, so
a wrapped line returns to the left margin. Needs per-line
indent in `TextAttrs`.
3. *A table.* Compose draws a real grid, cells wrapping at a
136dp floor; iris draws padded monospace columns. Same
information, different picture.
4. *Inline code.* Compose draws a chip behind it; iris gives
the range a monospace face and the code colour. Unchanged
by this box -- still blocked on per-range glyph geometry
(IRIS_TODO).
5. *A user message.* Compose draws it in a rounded card;
iris draws a sender label above plain text. That is the
row's own styling, P1's rather than P1a's.
**One real defect found and fixed on the way**, and it is
not a small one: **`Rect::is_size_independent()` answered
`true`.** A `Rect` fills whatever region it is given, so its
content *is* the region -- and `draw_inner`'s
size-independent fast path, which rewrites a widget's
primitives with `r.outside(&from).within(&region)` instead of
redrawing, cannot reproduce that remap once a region carries
both `rel` and `abs`. The visible result: a fenced block's
background kept the height of the **provisional full-region
draw** `Span` does in its first phase, so one fence's panel
covered every block below it *and every row below that*,
while the text underneath was laid out correctly. It answers
`false` now (`iris/src/widget/rect.rs`, with the account at
the definition). This is very likely the same family as this
file's older "the composer bar's grey background is not
drawn" note and any other `.background(rect(..))` tint.
**One defect found and left open**, with its repro:
`UiRenderState::reposition`'s debug assert -- *"widget ... is
both moved by its parent's own layout (`mov`) and
repositioned within it"* -- fires from `List::place` when a
transcript row's blocks **wrap**. Reproduce in one line:
change `.wrap(!verbatim)` to `.wrap(true)` in
`transcript-ui/src/row.rs`'s `build_block` and run
`iris/run-headless.sh transcript --shot /tmp/x.png -- -p
transcript-ui`. It is *not* caused by the `Rect` fix above
(it survives it) and not by any one block kind (bisected: it
appears once the row is tall enough). The shipping
configuration does not reach it -- verbatim blocks do not
wrap -- and neither does the Android bench, which ran clean
with the assertions live. It should be the next thing looked
at under P1, because it is a real inconsistency about who
owns a widget's move slot, not a false alarm.
**Bench, stream phase, this checkout's AVD, debug x86_64
`force-gles`, assertions live, no abort:**
`stream: 294 frames over 21.0s, late 283 (96.3%), p50 53.0ms
p90 108.6ms p99 132.0ms` against the pre-P1a
`p50 52.8ms p90 108.1ms p99 137.3ms` -- unchanged, which is
the point: block styling is span work, not layout work. The
`worst` figure is the one number that moved and it does not
reproduce: 567.3ms, 140.1ms and 664.5ms across three runs of
the same build, against 148.9ms before. Unexplained; it is a
single frame in 294 and the percentiles are flat, so it reads
as an emulator hiccup rather than a cost, but it is written
down rather than rounded off.
**Checks**: `cargo fmt --all --check` clean in both
workspaces; `cargo clippy -p iris -p iris-core -p
transcript-ui -p desktop-app -p tabs-ui --all-targets`
warning-free; `cargo test` 85 (iris, +4) + 13 (iris-core) +
31 (transcript-ui, +11) + 123 (client-core).
**2026-09-06, after P1a: "the emulator cannot draw iris's glyphs"
was iris's bug, not the emulator's.** The finding recorded in the
box above -- that every glyph is a solid filled box under `-gpu
host` GLES and that this is what an incomplete GL texture returns
-- had the mechanism right and the attribution wrong. It is a real
defect on **any** adapter that is GL rather than Vulkan.
- **Reproduced off the emulator entirely**, which is what made it
cheap: `default/render.rs` now honours the same `force-gles`
feature `android/render.rs` did, so
`./run-headless.sh transcript --shot /tmp/x.png -- -p
transcript-ui --features iris/force-gles` draws the boxes on this
machine's own GPU in seconds. Two shader probes then said what
the sample was: `return vec4(texel.rgb, 1.0)` drew black boxes and
`return vec4(texel.a, texel.a, texel.a, 1.0)` drew white ones, so
the atlas sample was exactly (0, 0, 0, 1) -- GL's answer for an
**incomplete texture unit**, and not the null texture (which is
zeroed, alpha 0).
- **Root cause: the glyph atlas array was created with one layer.**
`GpuTextures::new` started `array_capacity` at 1 and `grow_array`
only doubles once a page needs a layer past it, so the ordinary
case -- one atlas page -- is a one-layer array. wgpu-hal picks the
GL texture target from the descriptor alone
(`gles::Texture::get_info_from_desc`: `(false, 1) => TEXTURE_2D`),
so that array is created as a `GL_TEXTURE_2D` and then bound to
the shader's `sampler2DArray`. wgpu has a name for this
(`log_failing_target_heuristics`, its issues #1614/#1574); the
result is an incomplete unit, `texel.a == 1`, and `draw_glyph`'s
`color.a *= texel.a` paints the whole glyph quad.
- **Fix**: `MIN_ARRAY_LAYERS = 2` in
`iris/core/src/render/texture.rs` -- the array is never created
with fewer, with the account at `create_array_texture` and a
`debug_assert!` there so a future capacity arithmetic change fails
at the mistake rather than as boxes on a screen. Cost: one page of
texture memory, which the next atlas page uses anyway.
- **Not a regression from `3e72a4e..20303e0`, and the bisect was not
run.** The defect is a function of the layer count, not of any
commit in that range: it has been there since the atlas became a
`texture_2d_array` (TEXTURES.md, 2026-09-04) and it reproduces at
HEAD and disappears at HEAD with the one-line capacity change. The
claimed "visible text at `3e72a4e`" is a misreading of its own
evidence -- `/tmp/final-typing.png`, the screenshot that entry
cites, is boxes; what the agent read was the `iris text render:
chars=5 glyphs=5` log line, which reports what **parley shaped**,
not what reached the screen. The earlier genuinely-good emulator
shots (`/tmp/after3.png`, 2026-09-05 19:56) predate the APK being
built with `force-gles` at all, so they were the Vulkan path.
- **The phone build is not affected and does not need withdrawing.**
`android-app/build-apk.sh`'s default features are deliberately
without `force-gles` (`d73db97`'s comment), so a phone build takes
`Backends::PRIMARY` -> Vulkan, where a one-layer array is an
ordinary one-layer array and glyphs draw correctly -- which is
also what Iris's phone reports have shown all along. The fix
matters for any device that falls back to GLES, which is why it is
not just an emulator convenience.
- **Appearance testing on Android is back.** `build-apk.sh debug
--abi x86_64 --features "transcript-screen bench force-gles"` on
this checkout's AVD draws the transcript legibly --
`docs/bench/p1a-2026-09-06/iris-emulator-gles-fixed.png` -- so
P1b onwards can be checked here rather than only on the desktop
backend or Iris's phone.
**2026-09-06: the move slot has one owner.** IRIS_TODO's open
"a wrapped transcript row trips `reposition`'s debug assert" is
fixed rather than suppressed. `mov` accumulates a delta on a
widget's move slot and `reposition` overwrote it, and both
legitimately land on one widget in one frame: `List::place`'s
Bottom-known branch offers a row a same-size box that has *moved*
(`mov`), then corrects the placement inside it when the row's
cached height no longer matches what the row reports
(`reposition`) -- measured with a probe on the `.wrap(true)` repro:
`h=1604.7 height=548.7`, the row's own draw having updated
`active.size` without the list's height cache. The slot now means
`move_applied + repositioned` (both on `ActiveData`), so
`reposition` adds the move instead of dropping it and stays
idempotent, and the old assert is replaced by a `debug_assert_eq!`
that nothing *but* those two ever wrote the slot. Test:
`a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`
(`layout_tests.rs`), which draws the child at the offered position
rather than the placement without the fix. Checks: `cargo fmt --all
--check` clean, `cargo clippy --workspace --all-targets`
warning-free, `cargo test --workspace` 86 (iris, +1) + 13
(iris-core) + 31 (transcript-ui), the `.wrap(true)` repro drawing
correctly, and an emulator bench run with assertions live and no
abort (`2438 frames over 147.7s, p50 27.2ms`).
- [x] **P1b — tool-call cards and grouping.** Done 2026-09-06.
`ToolRows.kt`/`ToolInput.kt` ported to
`iris/transcript-ui/src/tool.rs` plus two new pure modules in
`client-core`. **Screenshots:
`docs/bench/p1b-2026-09-06/iris-tools-collapsed.png` and
`iris-tools-expanded.png`**, both from
`iris/run-headless.sh transcript -- -p transcript-ui` on the
desktop/winit backend (the emulator was not touched this pass
-- another agent held this checkout's AVD). The expanded one
is taken with `IRIS_TOOLS_EXPANDED=1`, which the example reads
to call `TranscriptScreen::expand_tail_tools` -- the expanded
appearance is otherwise unreachable on a machine with no
display and no finger.
**What the cards look like, against `ToolRows.kt`:**
- *A collapsed card* -- a mark, the tool's name (14pt), the
one-line summary `parse_tool_input` derives (12pt, Subtext
0, one line, clipped), and the state word at the far right.
Same as Compose, except that Compose ellipsises the summary
and iris clips it: there is no overflow-ellipsis in
`TextAttrs` yet (IRIS_TODO).
- *An open card* -- the timeout at the top right, the tool's
own description, the subject in a `Verbatim` panel with
`client_core::highlight`'s spans, the leftover input fields
under it, then the output. Same order as Compose.
- *A group* -- "Called N tools" (Compose's exact wording, and
so the name a `ui-trace` script taps), the cards on a Mantle
surface, and a chevron bar at the foot that closes it from
the end the reader is looking at.
- *States* -- `client_core::transcript_fold::ToolState`, five
of them, each with its own word and colour: nothing for
`Succeeded`, "running" (Subtext 0), "your turn" (Peach, the
Compose card's own wording and colour), "failed" (Red) and
**"no result" (Yellow)**. The last two are new -- Compose
can say neither.
**Two things the port had to add to be able to say "it
broke".** `event_model::Event::ToolEnd` gained `is_error`
(`#[serde(default)]`), read from the CLI's own `tool_result`
by one function used by both the live translator and the
import replay (`import::tool_result_is_error`); without it a
result was all a card had and a failed call drew exactly as
confidently as one that worked. And `ToolState` separates
`Succeeded`-with-empty-output from `NoResult`: both leave the
same empty string, and only the session's own status tells
them apart, which is why `TranscriptScreen::
set_session_working` exists and why only the *newest* row can
be "running" (every row behind it belongs to a turn that has
ended).
**Pass condition, met**:
`collapsed_cards_shape_only_their_summary_lines`
(`transcript-ui/src/lib.rs`) opens a group of three cards
whose calls carry 88 kB of output each and asserts the
text-shape count equals the same group's over three bytes.
**17 either way.** Confirmed to be a real test, not a
tautology, by pushing the output block into the collapsed
branch: **17 against 20**.
`a_result_arriving_redraws_one_card_whatever_the_run_holds` is
the second: one `ToolEnd` costs the same number of
`Widget::draw` calls in a twelve-call run as in a three-call
one.
**Three defects found on the way, all by looking at the
render rather than at the diff:**
1. **A `Span` of `Pad`ded children inside another `Span`
places those children a slot out of step.** Every card drew
its content one card's height below its own box, so the
group read as empty bars with somebody else's summary in
them. Bisected against
`IRIS_TOOLS_EXPANDED=1 iris/run-headless.sh transcript`:
removing the inner `Span` fixes it, and so does removing
the cards' own `Pad`; the card background, the `Sized`
wrappers and the per-card `WidgetPtr` all make no
difference. Worked around by building the group as **one**
`Span` (header, cards, collapse bar), which costs the 4dp
inset Compose holds its cards off the group's edge by. The
framework defect is still open -- IRIS_TODO has it, and it
is not the `mov`/`reposition` one f5b8893 fixed (it
survives that commit).
2. **`scrollable_on(Axis::X)` on a non-editable `Text` draws
nothing at all** -- an empty panel where the command should
be. A markdown fence does the same thing to a `TextEdit`
and is fine. So a card's verbatim block is `masked()` and
clips rather than panning; when this is fixed the pan
belongs there too, because the long command is the one
being read closely.
3. **`NotoSans-Regular.ttf` has no U+25B8/25BE/25B4** (read
out of the bundled `cmap`s) while `NotoSansMono-Regular`
does, so the expander mark is set in the monospace face at
the one place the character is written. The old
`build_tools` summary drew that codepoint in the sans face,
which was a missing glyph nobody had looked closely enough
to see.
**Checks**: `cargo fmt --all --check` clean in both
workspaces; `cargo clippy -p iris -p iris-core -p
transcript-ui -p desktop-app -p tabs-ui --all-targets` and
`cargo clippy --all-targets` in `client-core`/`server`/
`event-model` warning-free; tests 86 (iris) + 13 (iris-core) +
36 (transcript-ui, +5) + 137 (client-core, +11) + 160
(server, +1).
**Not done**: nothing on the emulator or the phone (the AVD
was another agent's this pass, so no frame times were taken);
a card's text is not selectable, unlike Compose's, since
`Selection` is keyed per markdown block and a card has none
(IRIS_TODO); no per-corner radius, so the "connected stack"
shape `connectedShape` draws is a 2dp gap instead;
`AskUserQuestionBody`/`PermissionAsk`'s answer buttons are not
ported -- an unanswered ask forces its card open and says
"your turn", but there is nothing to press yet, which is P1d's
modal/controls work.
- [ ] **P1c — history paging and jump-to-latest.** Wire
`client-core::transcript_source` into `transcript-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 `client-core` 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`. (`transcript-ui`
already covers the row/markdown/selection/composer core these sit
on top of or beside.)
**`client-core` 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 per
`DECISIONS.md`'s DEFERRED item.
- [ ] **P2 — the shell merge and a real phone install.** Merge this
screen's cdylib into the E3/E5 shell (`android-shell` +
`app/shellApp`) behind the same feature-flag pattern I5 used to
extend `iris-android-app` (`DECISIONS.md`, 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.
**`client-core` 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 `app-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`.
**`client-core` 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 `app-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`.
**`client-core` 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
`app-ui` the way it is called into from Compose today.
**`client-core` 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
`iris/desktop-app`, 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).
**`client-core` 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 `app-ui`/`android-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 agent
What to do when you pick this up, in order, so nothing here has to be
re-derived:
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,
`DECISIONS.md` 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 `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.
### Vulkan in the emulator (measured 2026-09-04)
**Settled 2026-09-04: the guest gets Vulkan from SwiftShader, and the
missing step was a cold boot.** `-feature Vulkan` plus
`VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json`
gets the *host* side to select SwiftShader, but the guest keeps reporting
zero devices until `-no-snapshot-load` is added, because it boots from a
snapshot saved under the previous GPU config — `-no-snapshot-save` is
worth adding too, so the Vulkan-configured snapshot does not then break
the next ordinary boot. With that, `cmd gpu vkjson` reports SwiftShader
Subzero and wgpu takes its Vulkan path (E1). `EMU_GPU=software` in
`emulator-tools` gets the same guest Vulkan with no GPU use at all, for
work where the emulator's frame rate is not what is being measured.
The rest of this section stands as the record of why host Vulkan is not
available. A `wgpu` app in this emulator was going to get GLES only,
because host Vulkan is switched off in `emulator-tools`. Retried on Mesa
26.1.7: **Venus still fails the same way** — gfxstream picks
`externalMemoryMode: OpaqueFd`, probes `VK_FORMAT_R8G8B8A8_UNORM` for an
exportable colour buffer, and Venus says the format is unsupported
(`Failed to find memory type for ColorBuffers`, fatal before adb sees the
device). Venus does advertise `VK_KHR_external_memory_fd` and
`VK_EXT_external_memory_dma_buf`, so the gap is specifically opaque-fd
image export. gfxstream has a string-valued `VulkanExternalMemoryMode`
setting ("overrides what would otherwise be determined automatically"),
but `-feature Name=Value` is rejected as a bad feature name, and the only
mode words compiled into this emulator's `libgfxstream_backend.so`
(37.1.11) are `OpaqueFd`, `Metal` and `none` — there is no dma-buf mode
in this build to switch to. So Venus is blocked by the emulator, not by
Mesa; retry when the emulator package updates, since upstream gfxstream
does have dma-buf external memory.
What **does** work: pointing the emulator's Vulkan loader at the software
ICDs the emulator ships itself, with the feature enabled:
VK_DRIVER_FILES=$HOME/Android/Sdk/emulator/lib64/vulkan/vk_swiftshader_icd.json \
GPU_HOST_FEATURES="-feature Vulkan" emu up
The guest then reports Vulkan 1.3 (`cmd gpu vkjson`, SwiftShader
Subzero) while GLES still runs on the real GPU through virgl — so a
Vello/wgpu app can take its real Vulkan path here, with compute shaders,
CPU-rasterised. That is enough to test *correctness* of the Vulkan path
in the emulator; GPU *performance* of it is a phone measurement either
way, exactly as `MACHINE.md` already says about frame times. **lavapipe**
(`lvp_icd.json`, the other ICD the emulator ships) selected llvmpipe and
booted, then the emulator died right after loading the `default_boot`
snapshot with nothing in the log; a snapshot saved under a different
Vulkan device is the suspect, and `-no-snapshot-load` is the untested
next step. SwiftShader is the one that works today.
`EMU_GPU=software` is now in `emulator-tools` (agreed with the ai-app
session and with Iris, default unchanged, since `-gpu host` was measured
and the Compose scroll benchmarks depend on it). The cold-boot flags are
not a knob there: that wants snapshot invalidation as well, which is a
bigger design question in shared tooling.
## 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.
## Sources
- iced: [repo](https://github.com/iced-rs/iced), [0.14 release](https://github.com/iced-rs/iced/releases/tag/0.14.0), [Android thread](https://news.ycombinator.com/item?id=46350641), [markdown selection request](https://discourse.iced.rs/t/markdown-widgets-text-should-be-selectable/1107)
- Linebender: [2026 Q1 report](https://linebender.org/blog/tmil-25/), [xilem](https://github.com/linebender/xilem), [parley](https://github.com/linebender/parley), [vello](https://github.com/linebender/vello), [vello_hybrid](https://docs.rs/vello_hybrid/latest/vello_hybrid/)
- android-view: [repo](https://github.com/rust-mobile/android-view); android-activity [PR #214](https://github.com/rust-mobile/android-activity/pull/214)
- winit Android IME: [#1823](https://github.com/rust-windowing/winit/issues/1823), [#2766](https://github.com/rust-windowing/winit/issues/2766), [#2305](https://github.com/rust-windowing/winit/issues/2305)
- egui on Android: [discussion #2053](https://github.com/emilk/egui/discussions/2053)
- Slint: [Android guide](https://docs.slint.dev/latest/docs/slint/guide/platforms/mobile/android/), [1.15 release](https://slint.dev/blog/slint-1.15-released), [licensing](https://slint.dev/faqs), rich text [#1325](https://github.com/slint-ui/slint/issues/1325), markdown [#6684](https://github.com/slint-ui/slint/issues/6684)
- Makepad: [repo](https://github.com/makepad/makepad), [makepad-widgets](https://docs.rs/makepad-widgets), [Robrix](https://github.com/project-robius/robrix), [Robrix releases](https://github.com/project-robius/robrix/releases)
- AccessKit: [releases](https://github.com/AccessKit/accesskit/releases)
- Build tools: [cargo-ndk](https://github.com/bbqsrc/cargo-ndk), [cargo-apk](https://github.com/rust-mobile/cargo-apk), [rust-mobile](https://github.com/rust-mobile)
- uniffi: [repo](https://github.com/mozilla/uniffi-rs), [KMP bindings fork](https://github.com/UbiqueInnovation/uniffi-kotlin-multiplatform-bindings)
- GPUI mobile: [gpui-mobile](https://github.com/itsbalamurali/gpui-mobile)
- The earlier Dioxus spike's findings on `wgpu`/Vulkan in this emulator: `~/repos/tdep-survey/app-dioxus/README.md`