Files
ai-app/docs/RUST.md
T
irisandClaude Fable 5.1 d01c105037 iris: stop requesting compute-shader limits nothing uses
adapter.request_device asked for Limits::default(), which requests
desktop-tier compute-shader limits unconditionally even though nothing in
iris/iris-core creates a ComputePipeline or writes a @compute stage. That
crashed device creation outright on a downlevel GL adapter reporting
OpenGL ES 3.0 (no compute at all) -- the Android emulator's
EMU_GPU=software/force-gles path, and any real GLES-3.0-only device.

New iris_core::device_limits(), shared by both platform backends, zeros
exactly the six max_compute_* fields rather than switching to a downlevel
Limits preset -- downlevel_webgl2_defaults() also zeros
max_storage_buffers_per_shader_stage, which shader.wgsl's vertex stage
needs. rigs/gpu-probe's own mirrored limits were updated to match.

Not verified against the actual SwiftShader-ES-3.0 crash on-device this
pass: the cold boot needed would have force-restarted this checkout's
emulator while another session had its own app running on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:18:30 -04:00

239 KiB
Raw Blame History

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, 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-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 and what could and could not be re-verified this pass -- this checkout's emulator turned out to be concurrently in use by another session's P0 benchmark work partway through verification (its sandbox server was restarted, wiping this pass's test session, and its Compose bench app took window focus), so the "run iris-scroll.sh three times cleanly" and "re-take the host-GPU FrameReport row" pass conditions could not be completed end-to-end this pass. The fix itself is verified by direct, targeted logcat traces taken before that interference began, not by the aggregate script.
  • 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. The specific SwiftShader-ES-3.0 crash this fixes was not re-verified on-device this pass -- the cold boot needed would have force-restarted this checkout's emulator while another session had its own app focused on it, so it was left rather than disrupted. 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 SIGSEGVs 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 -- cding 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 ndkjavac/d8aapt2zipalignapksigner, 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).
  • AttachmentsContentResolver reads of shared URIs, BitmapFactory decode and downscale, EXIF orientation.
  • SessionImage — bitmap decode for produced images.
  • ScrollAnchor, DraftsSharedPreferences; CrashLogfilesDir.
  • TranscriptCachecacheDir.
  • DebugStats/FrameStatsChoreographer frame timing and the render report; runtime-tracing names composables in a system trace.

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

How much Java is unavoidable, and why

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

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

Can the APK be built without Gradle?

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

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

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

The behaviours that are hard to get back

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

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

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, #2766) are open, ReceivedCharacter is unimplemented on Android (#2305), and the android-activity groundwork for editor actions only merged in February 2026 (PR #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: 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 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, 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).
  • Slint's own: TextInput with read-only is the selectable-text trick; there is no inline rich text at all (issue #1325, markdown request #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. 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; 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); 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). 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); 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). 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, 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 I0biris/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.

  • 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.

  • 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.
    
  • 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.
    
  • 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.
    
  • 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, and why** (`app.rs`'s module doc has the
    full account): every incoming SSE event refolds the session's whole
    item list and rebuilds the entire right-hand widget tree from
    scratch, rather than reaching for `TranscriptScreen::push_row`'s
    incremental append -- `push_row` can 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, and the real fix needs
    `transcript-ui` to expose updating a row already on screen, which it
    does not yet. The composer's in-progress text is saved and restored
    across a rebuild so a reply streaming in while the reader is typing
    a followup doesn't erase it. No history paging (I3's job, reused
    as-is if this becomes permanent) and no scroll-position preservation
    across a rebuild -- both named rather than silently missing.
    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.
    
  • 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 ndkjavac/d8aapt2zipalignapksigner 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.

  • 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" }.

  • 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.
    
  • 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) — 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) — 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.

  • 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.
    
  • 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 repositioned 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.
    
  • 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.
    
  • 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`) | **release**, `force-gles` | host (virgl) | 62 | 41.94% | 15.0ms | 21.8ms | 37.1ms | 37.1ms | 0.2ms | 12.9ms |
    
    **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.
    
    **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.
    
  • P1 — session screen parity. 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