iris APK only. Three corrections to the report, two of them shipped this morning: `missed vsyncs` counted the bench's own idle pauses as stutter, `late` counted the wait for the display as cost, and the display reported 60Hz for a run that drew at 120 -- so every late figure was judged against twice its budget. Also corrects the frame-rate reading in the previous entry: both runs sustained ~120.3fps through the motion, so the loop was never dropping frames and the clock is what made it smooth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ai-app bench
The two APKs for the phone benchmark gate (ai-app's docs/RUST.md, P0),
packaged for Dev Updater so they can be installed on a phone without a
build machine. Add this checkout as a project in Dev Updater (its
.dev-updater.ron names two APK components, compose-bench and
iris-bench, with no build step -- the APKs are committed under
compose/ and iris/), then install each from its card. Both install
beside the real app under their own application ids and need no server,
no enrolment and no permissions.
The rest of this file is the runbook that was delivered with the APKs.
2026-09-09 (later): the report itself was wrong in three places, and
two of them were mine from this morning. Rebuilt from ai-app-2
9bf714f, iris APK only. Your run said "now THAT is smooth" and the
report did not deserve the credit it was taking:
missed vsyncswas counting the bench sitting idle, not stutter. 276 for the sixteen 300ms rests between flings, 2410 for twelve hundred 50ms gaps between keystrokes, 821 for the 50ms between streamed events -- each within a few percent of just multiplying it out. A gap only means anything now if the frame before it had asked for another one.latewas counting the wait for the display as if it were cost. When the app is comfortably ahead, every frame is a little work then a wait for its turn, so the total sits exactly on the budget andlatebecomes a coin toss. It is judged on the work now.- Your display reported 60Hz for a run that drew 3405 frames in 33.1s.
A phone that varies its refresh rate answers with whatever mode it is
in when asked, so every
latefigure in that report was judged against twice the budget it should have been. The report now takes the larger of what the display claims and what the run actually sustained, and prints both when they disagree.
And I misread your frame rate. "103fps on a 120Hz screen" divided the fling phase by its whole duration, rests included; excluding them both runs sustained ~120.3fps. So the loop was never dropping frames -- the thing that made it smooth is the clock the fling is sampled on, which changes no frame count whatsoever. That is precisely why no number in either report showed it, and why your eyes were the instrument that mattered.
What is worth reporting back now: the fling: block's build p50
(iris's own work -- it was 0.4ms) and whether missed vsyncs is small.
The acquire figure beside it is the display pacing the app and wants to
be large.
Streaming is the remaining cost, and it is not the fold: folding an arriving event is 0.35ms and applying it to the widget tree 0.41ms, while the frame is 9.5ms on your phone. 401 streamed events move the item count from 652 to 654, so nearly every one is a delta into the same row -- the cost is re-shaping one growing message on every delta. Fixing it means changing how a row holds its shaped text, which I would rather design with you than just do.
2026-09-09: the fling. iris APK only -- rebuilt from ai-app-2
42d54ee; the Compose one is unchanged from 2026-09-08.
You reported stuttering when flinging that is much harder to notice with a finger on the scroll. Four things came out of it, and two of them were the report rather than the renderer -- so read the new report before reading the old numbers against it.
- The swapchain wait was being counted as iris's CPU time. The call
that hands back an image to draw into blocks until the compositor
frees one, and it was inside the span the report called
cpu_p50. An app comfortably ahead of the display spends most of every frame there, so a perfectly healthy fling read as several milliseconds of iris being slow. Each phase now saysbuild / acquire / submitseparately, and onlybuildis work. - Nothing could say a frame was never produced.
latecounts frames that cost more than a budget -- but a late frame still appears on the next refresh, while a frame that never happens leaves the previous one on screen twice, and that is what a stutter is. Every phase now carriesmissed vsyncs. That is the number to watch. - iris asked for its next frame at the end of the frame callback, and Android schedules that for the next refresh after the call -- so any frame whose work ran past the boundary registered too late and got the refresh after it. One frame over budget silently cost a second one. Your fling phase ran at 103fps on a 120Hz screen, which is the shape that makes. It asks first now.
- The fling was advanced on the wall clock, not on the vsync the callback carries. Frames are shown on an even cadence whichever clock computed them, so sampling the coast curve at "whenever the callback got to run" moves the transcript by an uneven distance every frame -- with no frame late enough to appear in any percentile. This is most likely the thing you were seeing, and it explains why a finger on the screen doesn't have it: a drag's positions come from the finger's own timestamps and never went through that clock.
Also opt-level for the Android build: it was tuned for size ("s"),
which costs a renderer its loop vectorisation. At 3, iris's own
per-frame work over the same warm fling goes from p90 0.15ms / p99 0.42ms
to p90 0.09ms / p99 0.26ms. The APK grows 9.7 -> 11.5 MB; say if you'd
rather have the size.
What is worth reporting back: whether the fling feels smoother with
a finger, and the fling: block's frame count and missed vsyncs. If the
count is near 120 x its seconds and missed is small, the loop is fixed;
if missed is still large, the cause is elsewhere and the new split says
where. The CPU side is already known not to be it -- a warm fling is p99
0.26ms of layout, with only one frame in six laying anything out.
2026-09-08: nothing on the transcript is unbounded any more,
and both APKs are new. Rebuilt from ai-app-2 afbc2ad. This one has
things to look at, unlike the last.
The edit-card lag you reported is fixed, and it was a framework bug. Not text layout: shaping and rasterising a 51,200-glyph block is 20ms, and the frame that drew it took 1.37 seconds. Redrawing a widget frees every primitive it owned and writes fresh ones, and finding each one's handle to renumber was a scan of everything that widget drew -- so one redraw cost O(its own glyph count squared). It is 29ms now, and linear. Expanding a big card should feel immediate; if it still doesn't, that is a different fault and worth saying so.
Both apps now cap long things behind "Show all N lines". Same rule and the same numbers in both, so this comparison is still renderer against renderer:
- a tool call's input -- 80 lines or 4 KiB. This is the one you asked
for; an
Edit'sold_string/new_stringused to arrive whole. - a tool call's output -- same bound. iris already did this; the Compose app didn't, and now does.
- a message, yours or the model's -- 200 lines or 16 KiB.
A reply still streaming is never capped, so watching one arrive is unchanged. A message is cut between markdown blocks rather than through one, so you should never see half a fence.
Raw text no longer wraps in the Compose app either -- a tool's leftover input fields and its output pan sideways, the way the command line already did. Both apps agree now.
Worth pressing: expand the biggest edit card you can find; press "Show all" on a card and on a long message and check the transcript doesn't jump under you; scroll a long output sideways.
2026-09-08: scrolling is one mechanism, and this build is
mostly structure. iris-bench-arm64.apk rebuilt from ai-app-2
4fdabc3, four commits on from the last one. There is deliberately no
new feature here and little that should look different -- it is the
scrolling rework you asked for after reading docs/SCROLL.md, plus the
two changes before it, so what this build is for is catching anything
that got worse.
- Everything a scroll position is made of now lives in one
ScrollControllerthat the two scrolling widgets own, instead of a protocol between aScrolland the list inside it. The list is no longer wrapped in a scroll area at all -- it is one widget doing its own layout and its own scrolling -- and theWidgettrait lost the three scrolling methods that existed only to connect the two. - A scroll delta's sign is now a screen direction (positive scrolls up or left) rather than "toward earlier content", which pointed the opposite way for a list laid out backwards. Nothing in this build lays one out backwards yet, so this is a latent fix rather than something to see.
- The one behaviour that genuinely changed: a scroll delta is now applied by the next frame's layout rather than the instant it arrives, because the layout is the only thing that knows where the content ends. Input is always followed by a frame, so this should be invisible -- if any gesture feels a frame behind your finger, that is the change to suspect and worth saying so.
- Earlier in the same run: one fling and one drag implementation for every scroll area (a code fence, the composer and the transcript now share exactly the same code), and the list's pin ("stay at the newest message") is separate from which way it is laid out.
What to look for -- all of it is "does this still work", not "is this new":
- Flick the transcript hard both ways: it should decelerate and stop exactly on the first and last message, never past either.
- Put a finger down on a coasting transcript: it should stop dead under your finger and then follow it.
- Flick a code block sideways while scrolling the transcript vertically somewhere else -- the two should not interfere.
- Scroll the composer's text when a message is long enough to need it.
- While a reply is streaming, the view should stay stuck to the bottom; scroll away and it should stop following, and coming back to the bottom should re-stick.
- Selecting text with a long press should still work and should not pan.
2026-09-08 (later still): the scroll lock, and the input box's clip.
iris-bench-arm64.apk rebuilt from ai-app-2 5e34dba. Both are from
your report on the bench build, and both have a test that fails without
the change.
- "If I try to scroll vertically while a horizontal scroll animation is still active, it stays locked to the horizontal scroll." Your diagnosis was the right one: it was global state, and the fling had nothing to do with it. A widget kept receiving presses for one frame after the finger left it (the frame that exists so a hover can end), and a press landing on something that is already coasting commits to a pan immediately, with no slop -- so a code fence grabbed the whole gesture from wherever on the screen you actually touched. Measured before the fix: a finger put down 500px above a coasting fence and dragged 160px down the screen moved the list by nothing at all. A press now only reaches the widget it is actually on. What to try: flick a code block sideways and, while it is still moving, scroll the transcript up and down somewhere else -- the transcript should follow your finger and the code block should carry on coasting on its own. Putting a finger down on the coasting block still catches it, whichever way you then drag, which is what Compose does.
- "The message input box doesn't clip correctly." It was clipped 12dp inside its own edge, so a long message was sliced through the middle of a line of text with a band of bare grey above the cut. The box itself is the clip now -- the same rounded rect that draws the bar is what the text is cut to, so text disappears under its edge. The padding still holds the text off the edge at the end you are typing at. What to try: type or paste more than six lines and scroll inside the box.
2026-09-08 (late): the four defects from your second report.
iris-bench-arm64.apk rebuilt from ai-app-2 a2e5e58. Everything here
comes from the report you sent, and each one has a test that fails
without the change -- but the phone is the only place three of them were
ever seen, so they want checking there.
- "Leaving and reopening the app also randomly moved the vertical scroll." The system's own swipe up from the bottom edge to leave the app was reaching iris as an ordinary flick released at speed, so the transcript flung while the app was in the background and you came back to a different place. A gesture the platform takes away is now a cancel rather than a release: no tap, no selection, no fling derived from it. What to try: scroll to somewhere you can recognise, swipe up to leave, come back -- it should be where you left it.
- "Flinging doesn't work in horizontal scroll areas." It does now, and not as something a caller opts into: every scroll area flings, on either axis, through the same code the vertical list already used -- the same curve and the same release thresholds Compose uses. Two things came with it that a sideways fence had never needed: the display's real density (a hardcoded value made a one-second coast run for forty-five), and catching a coasting fence with a finger, which now stops it at that sample instead of after a slop distance. What to try: flick a code block sideways, and put a finger down on one while it coasts.
- "Sometimes when I try to catch it while it's still moving it fails to stop." A row's text block drives the same gesture as the list around it, and it was being told its own pan had been cancelled on the frame that pan started -- so the drag ended the moment it began. A widget that is itself driving the gesture now ignores that.
- A row that changes height no longer paints its background at the old one. On the frame a row's measurement disagrees with the box it was offered, it is drawn again at its true box in the same frame rather than the next one -- so expanding or collapsing a tool card should not flash a background that is the wrong size for one frame.
2026-09-08 (evening): the crash on scrolling up, a devlog that can
show a panic, and icons from a font iris ships. iris-bench-arm64.apk
rebuilt from ai-app-2 9e301f3.
- The crash. The bench built a texture per disclosure triangle -- one per folded tool card, hundreds of copies of three pictures -- and the path that rebuilds the renderer for a new GPU device forgot which slot each was in, so a frame could ask for a texture that no longer existed and the app aborted. It is one texture per shape now (three for the whole screen), and a new device re-uploads what it had instead of forgetting it, so the handles stay valid. On the emulator this was reproducible on the first frame; there it now opens clean and takes 33 full-screen scrolls back through the fixture without complaint. Whether it was your crash is not confirmed -- nothing recorded what yours was, which is the other half of this build.
- The Runtime tab will show a panic now. It only ever had one line because, after a crash, Dev Updater's query starts the app for the provider alone -- no screen, so the code that replays the previous run's crash file never ran. The provider does that itself now, and the crash file carries the last 80 log lines from the run that died as well as the panic, replayed in order: what the app was doing, then what killed it, then this run. So if it still crashes, open Dev Updater's Runtime tab afterwards and send what is there.
- The disclosure triangles come from a font now. You were right that this should have been the icon font: the Compose app ships its own Nerd Fonts subset and iris shipped none, so its mark was a bare Unicode codepoint your phone had no face for. iris has its own subset now (992 bytes, three Material Design glyphs, same Mono face as the Compose app's), the drawn triangle is deleted, and a missing codepoint now fails a test rather than drawing nothing. If a mark is the wrong size or sits wrong beside the tool name, say so -- it is a font size now, so it is one number.
- Side effect worth knowing: switching away from the app and back no longer re-rasterises every glyph on screen.
2026-09-08: catching a moving list, and rounded code fences that clip
on the curve. iris-bench-arm64.apk rebuilt from ai-app-2 38bf630.
Two things from your last report, and both want checking on the phone
because neither is confirmed there yet:
- "Sometimes when I try to catch it while it's still moving (particularly if I drag) then it fails to stop & snap to where finger is." A finger put down on a moving list now stops the fling at that sample and drags from it with no slop wait, which is what Compose does. A catch you put down and lift without moving is no longer treated as a tap. What to try: flick hard, then put a finger down mid-flight and drag slowly — the content should be pinned to your finger from the first movement, not after a jump.
- Code blocks clip on their rounded corners. A mask carries a shape now rather than a rectangle, so a code fence's content is cut on the panel's own curve instead of an inner square, and the corners of nested clips multiply their alpha rather than fighting. Touch obeys the same shape: a corner rounded away is not pressable. What to try: scroll a code block sideways and look at its four corners, and try pressing just outside one.
Also in this build: the app no longer refuses to start on a device whose
only usable adapter is a GLES one (it falls back and says so in the log
rather than aborting), and primitives= in a frame report no longer
counts clip shapes that are never drawn.
If something is wrong, the Copy report button always copies now — the log tail comes with it, and Dev Updater's Runtime tab on this component reads the app's log directly off the phone.
2026-09-06: composing text + caret, composer layout, text survives app
switch. iris-bench-arm64.apk rebuilt on top of the fling/stream/type/
keyboard bench v2 above (same commit range, merged). Fixes from Iris's
phone report on the previous build: Gboard's composing keystrokes now sync
back to the app (InputMethodManager.updateSelection, previously never
called, which is why typing looked stuck until a space); swiping over the
composer bar no longer opens the keyboard (only a completed tap does,
confirmed on the emulator); and text no longer disappears after leaving
and returning to the app (a genuinely new renderer now clears the CPU-side
glyph cache instead of leaving it pointed at a destroyed GPU device). The
composer itself was also rebuilt as one widget with a height cap and
insets-aware bottom padding. Not fixed: composed/typed text still does
not become visible on screen at all, a separate, deeper bug found while
verifying the above -- ruled out as a widget-layout bug (a new unit test
proves the region math is correct), still open. See ai-app-2's
docs/RUST.md, P0 box, for the full account, what was verified on the
emulator, and what still needs a real device or Vulkan to isolate.
2026-09-06: bench v2 (fling/stream/type/keyboard). compose-bench-arm64.apk
rebuilt with the four-phase benchmark Iris asked for after using v1 on her
phone -- a real fling (12,000px/s through the list's own FlingBehavior,
not the old tween-based swipe, so it travels much further per gesture),
the existing 400-event/20s stream, a type phase (600 fixed characters
typed into the real composer then deleted, to exercise wrapping and the
transcript being pushed upward), and a keyboard phase (five show/hide
cycles). The report gains a per phase: block (frames/late/percentiles/
worst/duration per phase) ahead of the existing whole-run sections, and
bench: gains the fling phase's travel, the typed character count, and
how many of the ten keyboard toggles were confirmed by the platform. Full
spec and the emulator smoke run are in ai-app-2's docs/RUST.md, P0 box,
"Benchmark v2 (2026-09-06)". Same steps below still apply -- "Run
benchmark" is unchanged in name and location.
P0 benchmark APKs
Two arm64 builds, asked for in docs/RUST.md's P0 box (the phone benchmark gate Iris asked for 2026-09-05, "before P1 I'd like to see benchmarks & also maybe stress test on my own phone"). Each opens straight onto the same synthetic fixture transcript with no server, runs the same scripted scroll-and-stream benchmark, and produces a text report to paste back. Install both, run both, paste both reports back -- the gate is a comparison, not either number alone.
Compose (compose-bench-arm64.apk)
- Install it (it has its own application id,
com.example.aiapp.bench, and its own label "AI Sessions bench" -- it installs beside a real enrolled "AI Sessions" without touching it). - Open it. It goes straight to a session screen holding the fixture transcript -- no enrollment, no permission prompts.
- Tap the gear icon top-right ("Session settings").
- Tap "Run benchmark". It scrolls the transcript (the same 24-swipe loop
app/transcript-bench.shdrives on a phone from a computer), then streams 400 more fixture events in over 20 seconds pinned to the newest end (the same shapeapp/stream-bench.shmeasures), then copies a report to the clipboard and shows a toast. - Paste the clipboard back here (or in whatever you're pasting reports into). It is plain text
with units on every number: frame counts and percentiles, the draw-phase accounting, and a
bench:section this build added -- process CPU time, peak RSS, and battery current sampled once a second (says "unavailable" rather than a fabricated number where the phone can't answer, per this project's rule against showing an inferred value as a measured one). - If you want to run it again, "Run benchmark" can be pressed more than once -- each press resets the counters first, so the report only covers that run's own scroll and stream.
Built from app/androidApp's bench build type; see app/build-apk.sh bench and
app/bench-fixture/README.md for how the fixture it opens was generated (deterministic, checked
into the repo, never a real transcript).
iris (iris-bench-arm64.apk)
- Install it -- own application id,
dev.iris.android.demo.bench(anapplicationIdSuffixon the samedev.iris.android.demothe plain iris tabs demo uses, so it installs beside that one too), needs no permissions beyondINTERNET(unused here -- inherited from the same manifesttranscript-screenneeds, harmless for a build that talks to nothing). - Open it. Same fixture as the Compose build (the identical checked-in
app/bench-fixture/assets/transcript.jsonl), opened with no server throughclient-core's realfold_page-- there is no enrollment step here at all, iris's shell has none yet. - Tap "Run benchmark" (the whole screen is just two buttons above the transcript and a report
area below it -- no settings dialog to find it in). It runs the identical scripted loop: the
same 24-swipe/6-cycle scroll (900px over 200ms, 500ms apart, animated in ~60Hz steps rather than
jumped so real frames render along the way), then the same 400-event/20s streaming phase pinned
to the newest end, through
fold_event-- the same fold path a live SSE reply uses. - The report appears in the text area under the buttons (it is a selectable field, so it can be
copied by hand too) and is logged under logcat's
iris-android-apptag on the line startingiris bench report:. Tap "Copy report" to put it on the clipboard through the shell's ownClipboardManager(no toast -- watch logcat or the on-screen text for confirmation it worked). - Paste it back. Same units, most lines the same shape as Compose's -- frame count, janky
percentage, p50/p90/p99/worst -- plus iris's own CPU/GPU split (
cpu_p50/gpu_wait_p50, fromFrameReport::record_split) where Compose's report has its draw-phase accounting instead, and abench:-shaped tail with the same three added fields: process CPU time over the run (getrusage(RUSAGE_SELF)), peak RSS (/proc/self/status'sVmHWM), and battery current sampled once a second throughBatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)-- "no frames recorded" / "unavailable" rather than a fabricated number wherever the phone can't answer one, same rule Compose's report follows. - "Run benchmark" can be pressed again; it resets
FrameReportfirst, so a second run's report covers only that run.
Built with iris/android-app/build-apk.sh release --abi arm64-v8a (wraps cargo ndk -t arm64-v8a -P 26 -o app/src/main/jniLibs/ build --release --features "transcript-screen bench" plus gradle :app:assembleRelease, signed with the same ~/.config/ai-app/release.jks app/build-apk.sh
generates, and verifies the result with aapt2/apksigner). No longer built with force-gles
as of the 2026-09-06 rebuild below -- see that entry for why it was there and why it had to come
out for a phone build specifically.
2026-09-06, commit 46d3a6f: rebuilt after transcript_ui::TranscriptScreen::apply replaced
the full-rebuild-per-event streaming path in all three iris clients (docs/RUST.md's P0 box has the
before/after numbers) -- this is the build to use if comparing against an iris APK from before that
commit.
2026-09-06, second rebuild, ai-app-2 commit 46246ea: this build aborted on Iris's
phone (a Pixel, GrapheneOS) in AndroidRenderer::new, on the very first surface_changed, with
only wgpu error: Validation Error surviving into the crash report before Android truncated it.
Two things changed:
- A renderer-creation failure no longer aborts the process.
iris_core::UiRenderNode::newnow runs its bind-group-layout/pipeline creation inside wgpu error scopes and returnsResult<Self, String>instead of letting wgpu's default handler panic; the Android backend turns a failure into the adapter's identity, the exact limits and downlevel flags bind-group layouts validate against, and wgpu's own "Caused by" chain, logged as one line under theiris-android-applogcat tag and shown on screen as plain, selectable, scrollable text (aTextViewswapped in for the whole activity) saying to copy it and send it back. If this build still fails on the phone, that screen is what to screenshot or copy -- there is no more silent abort to chase through a truncated crash report. - This build no longer forces the GLES backend. Every earlier
iris-bench-arm64.apk(including the one above) was built with--features "... force-gles ...", a flag that exists only to force the emulator off its default software Vulkan and onto GLES for one specific frame-time measurement (RUST.md's I5) -- its own doc iniris/Cargo.tomlnever mentions real hardware.build-apk.sh's default feature list carried it into every arm64 build regardless, so the APK actually delivered to a phone was locked to GLES rather than the phone's own Vulkan driver. That is the named hypothesis for the crash (docs/RUST.md's P0 box has the full audit): GLES support for a storage buffer bound in the vertex stage (move_offsets,masks_layout) depends on the driver reportingGL_MAX_VERTEX_SHADER_STORAGE_BLOCKS > 0, which real Vulkan grants unconditionally but a phone's GLES path is not guaranteed to -- and RUST.md's own SwiftShader finding already flagged GLES-on-Android as the fragile backend for this exact shader. This build uses the default backend (Vulkan on a real device) instead.
If this rebuild still crashes, the on-screen report from point 1 names the real cause directly; paste it back rather than guessing further from a truncated log.
2026-09-06, later: bundled fonts, content-scale fix, Diagnostics control
Iris's first real-phone report on the previous rebuild (no crash) found four
defects the emulator never showed: every glyph disappearing on the first tap
or scroll (rectangles stay drawn -- not root-caused this pass, but a phone
report from this build's new Diagnostics page would help), bold words
rendering as blank gaps (fixed -- bundled Noto Sans/Noto Sans Mono, static
per-style faces, ahead of the platform's own fonts), text far too small
(fixed -- iris had no device-pixel-ratio handling on either platform before
this; density now divides window size, touch coordinates and the shader's
window uniform down to logical units), and the top buttons sitting under the
status bar (fixed -- a new AndroidAppState::on_insets_changed hook, unused
until now).
This build also adds a third Diagnostics button next to Run/Copy, filling the report area with adapter identity, font resolution, the atlas's view count, every wgpu error seen since surface creation, and the frame report -- "copy this and send it to Iris" via the existing Copy button.
Full account, including what's still unverified on a real phone, in
docs/RUST.md's P0 box in the ai-app-2 repo (rustify branch).
2026-09-06, later still: keyboard-wipe fix, a density unit for crisp text, header background
Iris's report on the previous rebuild, with screenshots: text now the right size but blurry; opening the keyboard still wipes every glyph (rectangles stay, only text disappears); the header buttons have nothing behind them and overlap the transcript text.
- Keyboard wipe:
surface_changedfires on every surface resize, not only a genuinely new surface -- showing the keyboard underadjustResizeis exactly such a resize. It was rebuilding the whole renderer (a fresh, empty glyph atlas) every time, while the CPU-side glyph cache kept coordinates into the old atlas. Fixed: reuse the existing renderer's device/atlas/buffers and only reconfigure the surface when one is already live. Confirmed on this checkout's own emulator two ways (a forced display resize and an actual soft-keyboard open) -- text stayed on screen both times. - Blur: the previous fix's global "logical size" stretch (window
divided by density, then displayed stretched back up) rasterised each
glyph small and displayed it bigger than it had texels for. Fixed with
the density-independent length unit Iris asked for the same day (a new
dpalongside the existing physical-pixel and relative lengths, resolved against the display's density at layout time) -- text now rasterises at the display's real physical resolution. - Header: only each button's own coloured rect painted anything, so
gaps between/around them showed the black clear colour one layer back,
and the row's reserved height did not match the (now density-correct)
transcript below it. Fixed with an opaque panel behind the whole row
and every header size moved onto the new
dpunit. - New: the bench build now auto-captures a diagnostics report ~500ms after the keyboard opens (logged, copied to the clipboard, and shown in a plain overlay with Copy/Close over the running app) -- so a report can come back even if a keyboard-triggered regression is still happening.
Not fully verified. This build has not been run on Iris's actual
phone. It was verified end to end on this checkout's own emulator
(run-bench.sh, no crash, 24/24 swipes, 400/400 streamed events;
before/after screenshots for the two keyboard-resize tests above). While
verifying, a separate, not-yet-fixed issue turned up: after a
keyboard-triggered resize, the header row appeared to render a second
time, out of place, inside the transcript area, and a tap meant for the
message field below landed on a header button instead (a benchmark run
started unexpectedly). Not root-caused, and not confirmed to be caused by
this pass's changes rather than something pre-existing -- worth watching
for on the phone and reporting back if seen. Full account in
docs/RUST.md's P0 box in the ai-app-2 repo (rustify branch).
2026-09-06, later still: Benchmark v2 (fling/type/keyboard), the header-duplicate finding, the ime-inset finding
New: "Run benchmark" now runs the same four-phase script the Compose
build's v2 does -- fling (8 flings out + 8 back at 12,000px/s through
List's own new fling, matching Android's real deceleration curve),
stream (unchanged), type (the same 600-character string, one character
per 50ms into the real composer, then deleted), and keyboard (five
show/hide cycles). The report gained a per phase: block (frames, late
%, p50/p90/p99, worst per phase) and the whole-run frames: line now
reads the display's real refresh rate instead of assuming 60Hz.
The header-duplicate bug from the previous entry: still not fixed. Reproduced reliably this pass (tap the composer field, screenshot after the keyboard opens -- the three-button row appears twice, once at its real position and once inside the transcript). One real, targeted fix was tried and ruled out: skipping the header rebuild when the status-bar inset hasn't actually changed (since a keyboard toggle also re-fires this callback for an unrelated reason) -- the duplicate still reproduced identically after that fix, so repeated rebuilding is not the cause. Still unexplained; needs either live instrumentation of the layout engine's two-phase span placement or a person with the phone.
Found, and partly explains why the keyboard phase and the
keyboard-open auto-diagnostics both read "not confirmed" on every
run so far: this app's AndroidManifest.xml uses
windowSoftInputMode="adjustResize" (needed so the keyboard resizes the
window instead of panning it off screen), and under adjustResize
WindowInsets.Type.ime()'s own inset amount is defined to read zero --
the window already shrank to avoid the overlap that inset would
otherwise report. Fixed the app to read WindowInsets.isVisible(ime())
(a boolean, unaffected by resize-vs-pan) instead of the numeric inset --
correct on its own terms, but on this emulator the insets listener still
never re-fires at all once the keyboard opens (a separate, deeper
platform behavior tied to this activity not being edge-to-edge, not
fixed this pass -- changing that is a bigger, riskier window-behavior
change than the time available justified). So the keyboard phase's "0
confirmed" and the auto-diagnostics not firing are both still open,
now for a specific, named reason rather than an unknown one.
Full account, including what was tried and ruled out, in
docs/RUST.md's P0 box in the ai-app-2 repo (rustify branch).
2026-09-06, evening: typing, launch layout, fling, per-block streaming, doubled rows
iris-bench-arm64.apk rebuilt from ai-app-2 commit 20303e0 (arm64 Vulkan release,
iris/android-app/build-apk.sh release --abi arm64-v8a --features "transcript-screen bench").
Everything since the 02:07 build, in one entry -- two intermediate builds went to the wrong
folder and never reached this repo:
- Launch layout: the black third below the composer was the bench shell's own empty report pane taking a third of the screen, not an inset bug. Sized to its content now.
- Typing: tapping the empty composer focused it with no caret, so every keystroke was dropped. Fixed, with a regression test. Text should appear as you type.
- Fling and swipe from the composer: dragging is now iris's own
DragGesturewith pointer capture. A fast swipe keeps moving after release; a pan starting on the grey bar is taken by the list from the frame it commits. - Doubled rows (the
Compacted:row, and the earlier doubled header): a widget redrawn through its ancestor while marked dirty never freed its previous primitives, so they were drawn every frame for the life of the process. Fixed; a debug-build assertion now checks every frame that no primitive outlives its widget. - Streaming re-lays out only the block the delta lands in. A message row is a column of one text widget per markdown block; a streamed delta replaces the last block only (one text shape per delta into a 100-paragraph reply, by counter). Emulator stream-phase p99 went 343ms -> 137ms. The stream phase on your phone is the number to look at (it was p50 18.2ms vs Compose's 13.4ms before).
- Selection is per block: a drag selects within and across blocks; copy joins them. Whether
a block should be the selection unit is yours to judge (
docs/DECISIONS.md, 2026-09-06). - The composer scrolls under a finger when it overflows its six-line cap.
- Also fixed: every widget's tap target sat short of its drawing by its last move (from launch); stale mask slots after a keyboard resize; a targeted-redraw abort on masked widgets; the full-screen diagnostics overlay on every keyboard open is gone (logged only).
- App-switch text loss did not reproduce on the emulator, so this build logs one line per
surface event with glyph counts. If text still vanishes on returning to the app, please send
adb logcat | grep -i irisfrom around the return;already_live=trueon the way back in names the cause outright.
Not in this build: markdown block styling (headings, highlighted fences, lists, tables, links)
and tool-call cards, which landed on rustify after it -- next build.
2026-09-07: the keyboard reopens, the composer moves for the IME, and a fling actually flings
From ai-app-2 commit ba0f2ea on rustify (iris-bench-arm64.apk, arm64 release,
minSdk is 29 now). This build answers all four items of your 22:16 report.
-
"I can't reopen keyboard by tapping on message box." Fixed. Dismissing the keyboard leaves the field focused, and the only code path that asked for the keyboard was the one that ran when a field became focused, so it never ran again. A tap on an already-focused field asks for it now, exactly as Android's own
EditTextdoes. Checked on the emulator both ways: with the fix the second tap opens it, without it the second tap does nothing. Swiping over the composer still summons nothing, which is the thing this could have broken. -
"Message box does not push up the scroll area." Fixed. The keyboard's height was being sent across to Rust as the boolean 1, so nothing had a number to pad by. Height and visibility are two separate values now. On the emulator the composer rises by exactly the keyboard's height and the transcript shrinks to match.
-
"Fling still doesn't work." Three separate faults, all fixed, and this is the one to test hardest because the emulator cannot reproduce your phone's input timing.
- A fast flick at 120Hz arrives as one or two events with the in-between positions batched inside them, and we were reading only the last position -- so the speed came out as zero. Every batched sample is replayed now, each with its own timestamp.
- Nothing in iris advanced a fling between input events at all. The benchmark drove its own fling loop, which is why the bench "flung" and a finger never did. Widgets can animate now.
- Once flings finally moved, one lasted 45 seconds: two constants were wrong (a hardcoded display density, and the scroll friction used where Android uses its own 0.84 tuning number -- a factor of 56 inside an exponential). A flick now coasts for the time Android's own formula says: 1.6s for a hard flick, 0.6s for a gentle one.
If it still does not fling on your phone, the one line that settles it is
adb logcat | grep "iris drag release"-- please send it.samples=1orspan=0.0msmeans the batched samples are not reaching us on your device; a sensible span withv=in the thousands andoutcome=Released(Some(...))means the gesture is measured correctly and whatever is wrong is after that point.outcome=Tappedmeans the flick never counted as a drag at all. -
"The textures are getting cooked after leaving the app and resuming." Fixed in
ba2afba, with a headless test, but not confirmed on a device: no emulator here has a Vulkan adapter and the GLES path may not destroy the surface at all, so this one needs your phone. The cause was one cache deeper than the last build's fix reached -- each text widget kept its own laid-out glyphs with the old atlas's coordinates and re-submitted them verbatim, which is why only text drawn after the resume looked right. The atlas carries a generation now and a cleared atlas invalidates every cached layout at once.
Also in this build, both found while checking the above: a tap on the composer aborted every debug build (a fixed bound on how deep a widget can nest was 16 and the composer is 17 deep -- in a release build like this one it did not crash, it silently drew and hit-tested that part of the tree in the wrong place), and the app now requires Android 10 or newer, because the per-sample touch timestamps the fling fix needs are API 29.
Still not fixed, and unrelated to any of the above: the composer bar's grey background is not drawn on this build, so the transcript shows through behind the message field.
2026-09-07, later: the fling curve was a straight line, and the keyboard was one build setting
iris-bench-arm64.apk rebuilt from ai-app-2 rustify commit 4274b8b.
This is the build for your three lines on the last one.
"It seems to just be linear velocity with an abrupt stop." It was exactly that, and not approximately -- the curve iris used to shape a fling returned a straight line for every input. Android's own deceleration curve is built by a loop that solves one cubic and samples a different one; iris had both halves solving and sampling the same one, which made the two tables it builds identical, and the lookup that reads one against the other then cancels out to "distance is proportional to time". So a flick coasted at its release speed for the whole (correctly computed) duration and then stopped dead at the end of it.
It is ported exactly now, from Android's OverScroller.java and from
Compose's own copy of the same curve, which agree line for line -- so a
fling here should travel and slow the way every other Android scroll view
does. On the emulator a released 3750px/s now decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s across 32
frames, where before it would have held 3750 for all of them. What to
look for: a flick should visibly slow before it stops, and a longer flick
should slow over a longer distance. Two things that also should hold, both
checked here: a flick into the top or bottom of the transcript stops there
without bouncing, and putting a finger down during a fling stops it dead.
Worth saying because it is the reason this took two builds: every test the fling code had compared it against itself -- is it monotonic, is the sign right, does it add up to the total -- and all of them pass on a straight line. The expected numbers are now generated by a separate transcription of Android's source that does not share a line of code with iris.
"The keyboard raising up does not push things upwards." This one was a single line of the bench app's build file: it declared it targeted Android 14, while the real app (the Compose one, whose keyboard does push its transcript up on your phone) declares 17. Below the Android 15 target line a window keeps the old behaviour, where the system shrinks the window for the keyboard and therefore reports the keyboard's height to the app as zero -- nothing left to push up. The emulator here is a version where the old opt-out still works, which is why it pushed up in every test run and not on your phone.
This one is a reading, not a measurement -- there is no Android 17
device I can reach. So the other half of the change is making your phone
able to answer it without a log. Press Diagnostics with the keyboard
open and screenshot the line beginning insets:. It looks like
insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false
ime_bottomin the hundreds, and the message box has risen: fixed.dispatchescounting up butime_bottom=0: my reading was wrong and the window is still being resized. That is the useful failure.dispatches=0: the system is not telling the app about insets at all, which is a third thing again and would say so in words instead of numbers.
The keyboard's height also arrives over a second path now (the animation callback), so the push-up follows the keyboard up rather than jumping when it lands.
"The resume glyph corruption is fixed." Thank you -- closed on your evidence. That was the one nothing here could ever have confirmed: no emulator on this machine has a Vulkan adapter, and the software path may not destroy the surface at all, so the state you photographed was not reproducible here.
Still not fixed, and still unrelated: the composer bar's grey background is not drawn on this build, so the transcript shows through behind the message field.
2026-09-07, evening: clamped scrolling, a faster flick start, platform fonts, enrollment, and a runtime log Dev Updater can read
iris-bench-arm64.apk rebuilt from ai-app-2 rustify commit 92985ba,
against the last delivery (b47eb73, the fling-curve + keyboard-targetSdk
build). APK 9,639,224 bytes, libmain.so 7,506,448 bytes (arm64-v8a
only) -- both smaller than that build, mostly from replacing the bundled
Noto fonts with the platform's own (Roboto for body text, Droid Sans Mono
for code) and a further release-profile tuning pass; docs/RUST.md has the
full size table.
What changed:
- The list is now clipped to its own box and rows are drawn at both edges -- nothing scrolls up behind the header, and there is no blank band at either end.
- Scrolling is clamped at both ends of the transcript, matching the fling fix from the last build: you cannot drag or fling past the top or the bottom.
- A released flick now starts at the speed your finger was actually moving at, using the same least-squares velocity estimator Compose itself uses, rather than a slower approximation -- a flick should feel like it starts fast and decelerates, not like it ramps up.
- The clock that timestamps each touch sample was misdating batched input, which fed wrong numbers into that velocity estimate; it now dates them correctly.
- Code blocks render in a real monospace font and body text in Roboto -- both platform fonts now, not the bundled Noto this build removes.
- The app enrols itself from the
aiapp://enrolllink -- Dev Updater's Enroll button on this APK's component opens it directly. Diagnostics shows eitherenrolled: host:portornot enrolled. - The app exposes its own log to Dev Updater: APK component -> log button -> Runtime tab. This needs the updated Dev Updater build on the host to show anything.
- A "Trace" switch in the bench header turns on a line per input and per frame in that log, for when a report alone isn't enough to say what happened.
- Several of the invariants this code relies on internally are now hard assertions in this release build, rather than silently-wrong behaviour if one is ever violated.
What to look for:
- The header shows nothing behind it, at either edge of a scroll.
- You cannot scroll past the top or the bottom of the transcript.
- A flick starts at roughly the speed of your finger and visibly decelerates, rather than ramping up or holding a constant speed.
- Code blocks are monospace; body text is not.
- Opening the keyboard pushes the composer up. If it doesn't, screenshot
Diagnostics'
insets:line as in the last build's report. - Dev Updater's Runtime tab (APK component -> log button) shows this app's own lines, once the host's Dev Updater is updated.
- With Trace on, do a few flicks, then press Copy in the render report -- the input log that comes back is what to send back here.