diff --git a/docs/IRIS.md b/docs/IRIS.md index 8dd1e74..7e467e2 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -8,6 +8,42 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-07: the fling curve was the identity function + +You said the fling "seems to just be linear velocity with an abrupt stop." +It was, exactly: `android_fling_spline`'s lookup returned `t` for every +`t`. Two halves of AOSP's spline build loop had been transposed, which made +its two tables identical, and the lookup interpolated one against the +other -- which reduces algebraically to `t`. So a fling coasted at its +release speed for the whole (correctly computed) duration and stopped dead +at the end of it. + +Ported exactly now from `OverScroller.java` and Compose's +`SplineBasedDecay.kt`, which agree line for line. One public addition: + +**`FlingCalculator::velocity_at(velocity, elapsed) -> f32`**, beside the +existing `position_at` -- AOSP's `mCurrVelocity` and Compose's +`FlingInfo.velocity`. It is what makes "is this decelerating" answerable +rather than inferred, and it is what `List::tick_fling`'s new +`iris fling tick:` debug line reports each frame. + +The lesson worth keeping, since it cost two builds on your phone: every +test the calculator had compared it with itself -- monotonic, correctly +signed, integrates to the closed form, per-tick deltas non-increasing -- +and **all of them pass on a straight line**. The numbers now come from +`iris/benches/fling_spline_reference.py`, a separate hand transcription of +the two sources, checked in beside the tests. + +## 2026-09-07: the Android insets bridge counts its own dispatches + +`AndroidUiState::insets_report() -> String` is new, and the bench app's +Diagnostics pane shows it. It carries the last insets plus **how many times +the platform has delivered any**, because "the keyboard did not push +anything up" has two causes that look identical on screen -- the listener +never fired, or it fired with a zero height -- and you have no logcat on +the phone. `dispatches=0` prints a sentence saying so rather than the +numbers, which would be defaults rather than measurements. + ## 2026-09-07: widgets can animate, and a fling finally moves Iris's phone said "fling still doesn't work" twice. The velocity was only diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 154bd24..cc4395c 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -351,13 +351,27 @@ agent ticks it here with the evidence. Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan), `content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence. -- [ ] **"Fling still doesn't work."** *(Three defects fixed 2026-09-07; - open until the phone says so. **The line to look for:** - `adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms` - means the batched samples are not reaching the tracker there, while a - sensible span with `v=` in the thousands and - `outcome=Released(Some(…))` means the gesture was measured right and - anything still wrong is downstream.)* Second report; the emulator's +- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07: + *"flinging now does technically do something, but it seems to just be + linear velocity with an abrupt stop."* **It was exactly that, and the + arithmetic said so.** `distance_fraction(t)` returned `t` for every `t` + -- a constant-speed slide for the whole duration, then a stop at full + distance -- because two halves of AOSP's spline build loop were + transposed, which made `SPLINE_POSITION` and `SPLINE_TIME` identical, and + the lookup bracketed `t` between `SPLINE_TIME` entries rather than + between even time steps. The two cancelled to the identity. Ported + exactly now from `OverScroller.java` and + `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` + (they agree line for line), with `iris/benches/fling_spline_reference.py` + as an independent transcription supplying the numbers the tests assert + on. Emulator, 2026-09-07: a released `v=3750` decelerates + `3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s` across 32 + frames; a flick into the end of the list stops there in one tick with no + overshoot; a tap 200ms into a fling ends it at 11 ticks instead of 32. + **Open until the phone says so** -- a flick should now visibly slow + before it stops. Its earlier three defects (the velocity, the missing + animation registration, the 56x coefficient) are all still fixed and were + never the linear part.* Second report; the emulator's `ui-trace` swipe flings (verified 2026-09-06 with `render()` counts), a finger on the phone does not. What differs: a real flick at 120Hz is batched by Android into few `MotionEvent`s with *historical* samples @@ -383,11 +397,29 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan), `EditText` shows the IME on every tap of a focused field; do the same (`FocusHost`: a tap on a focused field requests the IME, idempotent when it is already shown). -- [x] **"Message box does not push up the scroll area."** *(Fixed - 2026-09-07: height and visibility are two JNI values now. Emulator: - `iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer - box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows - because it is `rest(1)` in the same `Span`.)* Since +- [ ] **"Message box does not push up the scroll area."** + **Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard + raising up does not push things upwards"* -- after being ticked on + emulator evidence the day before (`ime_bottom=883`, composer box + `31,2277..1048,2329` -> `31,1457..1048,1509`). The JNI half was right; + what was wrong is one line of `iris/android-app/app/build.gradle`: + **`targetSdk = 34`** against `compileSdk = 37`, while the Compose app in + `app/` targets 37 and *does* push up on her phone. Below target 35 the + window keeps the legacy behaviour, where `adjustResize` shrinks it for + the IME and `getInsets(ime()).bottom` therefore measures zero; + `setDecorFitsSystemWindows(false)` opts out of that and still takes on + the API 36 emulator here, which is why every test run passed. Now + `targetSdk = 37`, plus a `WindowInsetsAnimation.Callback` for the devices + where only the animation path carries the height -- which also makes the + push-up animate (`ime_bottom=509, 663, 833, 881, 883` instead of one + jump). **This is a reading, not a measurement**: no Android 17 device is + reachable from here. So the Diagnostics pane now prints + `insets: dispatches=N left=… ime_bottom=… ime_visible=…` -- + **screenshot that line with the keyboard open.** `ime_bottom` in the + hundreds and the composer risen means fixed; `dispatches` climbing with + `ime_bottom=0` means the reading was wrong and the window is still being + resized; `dispatches=0` means the listener is not firing at all, which is + a third thing again.* Since `MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no longer resizes the window, so the app owns the IME inset -- but `ime_bottom` is passed through JNI as the boolean `1`/`0` (the @@ -418,11 +450,12 @@ Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan), screenshotted the emulator's GLES path, where a resume may not destroy the surface at all. - **Fixed in `ba2afba`, with `clearing_the_atlas_re_renders_cached_text_ - instead_of_reusing_it` run and passing (2026-09-07). Ticked on the - code; still wants phone-side confirmation** -- no emulator here has a - Vulkan adapter, and the GLES path may not destroy the surface at all, - so the emulator cannot reproduce the state Iris photographed. + **Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07: + "the resume glyph corruption is fixed").** Closed. The emulator could + never have settled it -- no Vulkan adapter here, and the GLES path may + not destroy the surface at all -- so the phone was the only place this + could be answered, and it has been. `clearing_the_atlas_re_renders_ + cached_text_instead_of_reusing_it` is what keeps it. The reading above is right and the mechanism is one step narrower than "cached text primitives". `IrisViewPeer::surface_changed` diff --git a/docs/RUST.md b/docs/RUST.md index 1ff8281..694803d 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -115,6 +115,129 @@ velocity and a moved scroll offset; one command opens the same screen in a phone-shaped window and screenshots it. Record the commands here when it lands. +### The 2026-09-07 phone report on `ed04d4c`: the fling was linear, and the keyboard is a targetSdk + +Iris's three lines on the `ed04d4c` build (Pixel 9 Pro XL, GrapheneOS +Android 17, Mali-G715, `content_scale` 2.55, 120Hz): item 4 (the resume +glyph corruption) **is fixed**, confirmed on the phone; "flinging now does +technically do something, but it seems to just be linear velocity with an +abrupt stop"; and "similarly, the keyboard raising up does not push things +upwards." + +**The fling was exactly, arithmetically linear.** Not approximately. +`android_fling_spline::distance_fraction(t)` returned `t` for every `t`, +which is a constant-speed slide for the full `duration()` and then a stop +at full distance -- Iris's sentence, read straight off the code. Two +transposed halves of one AOSP loop did it, and they compounded: + +1. AOSP's `SplineOverScroller` static initialiser **solves** the bisection + on the `P1`/`P2` curve and **samples** `SPLINE_POSITION[i]` from the + tension curve (`coef * ((1-x) * START_TENSION + x) + x³`); the second + half of the loop does the reverse to build `SPLINE_TIME`. iris had both + halves solving on the tension curve and sampling `P1`/`P2` -- so its two + loops were the *same computation*, and `SPLINE_POSITION == SPLINE_TIME` + element for element. +2. The lookup then bracketed `t` between **`SPLINE_TIME` entries** and + interpolated `SPLINE_POSITION`. AOSP brackets between the even time + steps `index / N` and `(index + 1) / N` (`SPLINE_TIME` is used only by + `adjustDuration`, which iris has no analogue of). With the two arrays + identical, `d_inf + (d_sup - d_inf)(t - t_inf)/(t_sup - t_inf)` reduces + to `t_inf + (t - t_inf)` = `t`. + +Every test the calculator had compared it with itself -- monotonic, signed, +integrates to the closed form, per-tick deltas non-increasing -- and all of +them pass on a linear curve. That is the shape to distrust: `<=` is not +deceleration. + +**Sources, read rather than remembered.** `frameworks/base` +`core/java/android/widget/OverScroller.java` from +`android.googlesource.com` (`?format=TEXT`, base64), and +`androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` and +`FlingCalculator.kt` out of the `-sources.jar` on +`dl.google.com/dl/android/maven2` (there is no androidx checkout here and +`cs.android.com` is JS-only; `androidx.tech` is now a parked domain serving +an unrelated site). The two agree line for line, which is why iris ports +one curve rather than two. The formulas, for the record: + + P1 = START_TENSION * INFLEXION = 0.5 * 0.35 + P2 = 1 - END_TENSION * (1 - INFLEXION) = 1 - 1 * 0.65 + SPLINE_POSITION[i]: solve coef*((1-x)P1 + xP2) + x³ = i/100 for x, + then take coef*((1-x)ST + x) + x³ + physical_coeff = 9.80665 * 39.37 * density * 160 * 0.84 + l = ln(0.35 * |v| / (0.015 * physical_coeff)) + distance = 0.015 * physical_coeff * exp(rate/(rate-1) * l) + duration = exp(l / (rate - 1)), rate = ln(0.78)/ln(0.9) + at time t: index = floor(100 * t/duration) + vcoef = (POS[index+1] - POS[index]) * 100 + position = distance * (POS[index] + (t/duration - index/100) * vcoef) + speed = vcoef * distance / duration + +**What changed.** `iris/src/sense.rs`'s `android_fling_spline` builds one +table, indexed by even time steps, and `sample(t)` answers AOSP's +`distanceCoef`/`velocityCoef` pair; `FlingCalculator` gained `velocity_at` +beside `position_at`. `iris/benches/fling_spline_reference.py` is an +independent hand transcription of both sources and prints the numbers the +tests assert on -- checked in because "numbers computed by the code under +test" is exactly how the last three tests passed through this defect. +Tests: `the_spline_matches_aosps_own_table` (the curve is not the +identity: 27.4% of the distance at a tenth of the time, 85.8% at half), +`a_flick_decelerates_the_way_aosp_says_it_does` (11064px/s at density +2.55: 6334px over 1.636s, speed 9202 -> 4733 -> 2650 -> 951px/s), and +`tick_fling_applies_shrinking_incremental_deltas` strengthened from +"non-increasing" to "the last delta is under 80% of the first". **Negative +control run**: with `sample` forced back to returning `t`, exactly those +three fail and the other eleven pass. + +**Emulator evidence (API 36 AVD, debug, `force-gles`, 2026-09-07).** A +`ui-trace` swipe of 900px in 120ms releases at `v=3750` and the new +`iris fling tick:` debug line reports, frame by frame, +`speed=-3746 -> -2624 -> -1834 -> -1144 -> -752 -> -449 -> -243 -> -83px/s` +over 32 frames ending at `t=0.664s`, with the per-frame `dy` falling +`94 -> 34 -> 20 -> 13 -> 8 -> 4.4px`. **The end**: the same flick in the +other direction, from a list already at its newest end, produces exactly +one tick and stops -- no overshoot. **A finger during a fling**: swipe, +then a tap 200ms later, ends the fling at `t=0.248s` and 11 ticks instead +of running its full 0.55s. + +**The keyboard: the bench app targeted SDK 34.** `app/build.gradle` said +`targetSdk = 34` while `compileSdk` was 37 and the Compose app in `app/` +targets 37 -- and that Compose app's keyboard *does* push its transcript up +on Iris's phone. Below target 35 a window keeps the legacy behaviour, where +`adjustResize` shrinks the window for the IME so `getInsets(ime()).bottom` +measures the overlap with an already-shrunk window and is zero; +`MainActivity`'s `setDecorFitsSystemWindows(false)` opts out of that and +still takes on the API 36 emulator here, which is why every test run showed +the push-up working. It is deprecated as of API 35 and Android 17 is where +it appears no longer to. Fixed by `targetSdk = 37`, where edge-to-edge is +not opt-in. + +That is a *reading*, not a measurement -- no Android 17 device is reachable +from here -- so the second half of the change is making the phone able to +answer it. `MainActivity` now also registers a +`WindowInsetsAnimation.Callback` (`DISPATCH_MODE_CONTINUE_ON_SUBTREE`, +`onProgress` forwarding, `onEnd` re-reading `getRootWindowInsets` so an +interrupted animation cannot leave a frozen value), which delivers the IME +height on devices where only the animation path carries it and, on every +device, makes the push-up *animate* with the keyboard: the emulator log now +shows `ime_bottom=509, 663, 833, 881, 883` instead of one jump to 883. And +`insets::Shared::updates` counts every dispatch, which +`AndroidUiState::insets_report()` puts in the **Diagnostics pane**: + + insets: dispatches=27 left=0 top=142 right=0 bottom=63 ime_bottom=0 ime_visible=false + +Screenshot-verified on the emulator. Iris has no logcat, and "the listener +never fired" and "it fired with a zero height" look identical on screen; +`dispatches=0` prints its own sentence instead of the numbers, since those +would be defaults rather than measurements. + +**What to look at on the next build.** Open the keyboard, press +`Diagnostics`, screenshot the `insets:` line. `ime_bottom` in the hundreds +with the composer risen: fixed. `dispatches` climbing but `ime_bottom=0`: +the targetSdk reading was wrong and the window is still being resized. +`dispatches=0`: the listener is not being called at all, which is a +different fault from either. For the fling, a flick should now visibly +slow before it stops rather than running out at speed. + ### The 22:16 phone report, worked 2026-09-06/07 Iris's four items are listed in docs/IRIS_TODO.md's "From the phone, diff --git a/iris/android-app/app/build.gradle b/iris/android-app/app/build.gradle index 910e106..a13c6f6 100644 --- a/iris/android-app/app/build.gradle +++ b/iris/android-app/app/build.gradle @@ -22,7 +22,28 @@ android { // untested fallback path is its own defect. `build-apk.sh`'s // `cargo ndk -P` is kept at the same number. minSdk = 29 - targetSdk = 34 + // 37, matching `compileSdk` and the Compose app in `app/` -- which + // is the one part of this that is measured rather than reasoned: + // that app targets 37 and its keyboard does push the transcript up + // on Iris's phone, and this one targeted 34 and does not + // (2026-09-07). The emulator here is API 36 and the push-up works + // there at either target, so the target is the only difference the + // two devices do not share. + // + // The mechanism, stated as the reading it is: below targetSdk 35 + // a window keeps the legacy behaviour, where `adjustResize` shrinks + // the window for the IME and `getInsets(ime()).bottom` therefore + // measures the overlap with an already-shrunk window -- zero, with + // nothing left to push up. `MainActivity`'s + // `setDecorFitsSystemWindows(false)` opts out of that, and on API + // 36 it still takes; Android 16 deprecated it and Android 17 is + // where it appears not to. At 35+ edge-to-edge is not opt-in, so + // the app is handed the real overlap without relying on a + // deprecated call. If the phone still reports `ime_bottom=0` with + // a nonzero `dispatches` in the Diagnostics pane, this reading was + // wrong and the `WindowInsetsAnimation.Callback` in + // `MainActivity` is the other half to look at. + targetSdk = 37 versionCode = 1 versionName = "1.0" } diff --git a/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java b/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java index aa87090..de2cbda 100644 --- a/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java +++ b/iris/android-app/app/src/main/java/dev/iris/android/demo/MainActivity.java @@ -4,7 +4,9 @@ import android.app.Activity; import android.os.Build; import android.os.Bundle; import android.view.WindowInsets; +import android.view.WindowInsetsAnimation; import android.widget.FrameLayout; +import java.util.List; /** * The android-view backend's demo activity (RUST.md's I2): one IrisView @@ -50,39 +52,88 @@ public final class MainActivity extends Activity { getWindow().setDecorFitsSystemWindows(false); } + // **The keyboard's height arrives twice, over two different + // paths, and the phone needs the second one** (Iris, 2026-09-07: + // the emulator pushed the composer up and her Pixel did not). + // `setOnApplyWindowInsetsListener` is the platform's *settled* + // answer; `WindowInsetsAnimation.Callback` is the running one, and + // an IME that animates in delivers every intermediate height + // through the callback with the static dispatch arriving only at + // the ends -- on some devices only at `onEnd`. Registering both + // means neither device depends on the other's timing, and it is + // also what makes the push-up *animate* with the keyboard rather + // than jump when it lands. + // + // The two do not disagree, because they are the same call with the + // same numbers read out of whichever `WindowInsets` is current. + // `DISPATCH_MODE_CONTINUE_ON_SUBTREE` so this view consuming + // nothing keeps the ordinary dispatch running underneath. + // `onEnd` re-reads the root's insets rather than trusting the last + // `onProgress`: an animation interrupted mid-flight never delivers + // its final frame, which is exactly the fault the Compose app hit + // (AGENTS.md, "the composer can get stuck floating above the + // bottom of the screen"). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback( + WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) { + @Override + public WindowInsets onProgress( + WindowInsets insets, List running) { + sendInsets(view, insets); + return insets; + } + + @Override + public void onEnd(WindowInsetsAnimation animation) { + WindowInsets settled = view.getRootWindowInsets(); + if (settled != null) { + sendInsets(view, settled); + } + } + }); + } + view.setOnApplyWindowInsetsListener((v, insets) -> { - int left = insets.getSystemWindowInsetLeft(); - int top = insets.getSystemWindowInsetTop(); - int right = insets.getSystemWindowInsetRight(); - int bottom = insets.getSystemWindowInsetBottom(); - // **Two separate answers, because they are separate questions** - // (Iris's phone, 2026-09-06: "message box does not push up the - // scroll area"). `isVisible(ime())` says whether the keyboard is - // up; `getInsets(ime()).bottom` says how tall it is. An earlier - // pass sent the boolean *as* the height (0 or 1) because under - // plain `adjustResize` the window shrinks to make room and the - // ime inset therefore measures a zero overlap by construction -- - // true then, and no longer true now: `setDecorFitsSystemWindows - // (false)` above makes this an edge-to-edge window, which is - // exactly the case where the system stops resizing and hands the - // app the real overlap instead. Sending 1 for it left the Rust - // side padding the composer by one physical pixel, so the - // keyboard covered the bar and the transcript alike. - // - // The visibility is still sent in its own right rather than - // inferred from `height > 0`: the two disagree during the - // keyboard's slide-in and -out (visible, height still climbing), - // and "is the IME up" drives the bench's own state machine - // (`bench_client.rs`'s `ime_state`) where a half-open frame - // reading as "closed" is a miscount. - int imeBottom = 0; - int imeVisible = 0; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; - imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0; - } - ((IrisView) v).applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible); + sendInsets((IrisView) v, insets); return insets; }); } + + /** Read one `WindowInsets` and hand it to the Rust side. The only + * place that reads these fields, so the static dispatch and the + * animation callback above cannot come to report different things. */ + private static void sendInsets(IrisView view, WindowInsets insets) { + int left = insets.getSystemWindowInsetLeft(); + int top = insets.getSystemWindowInsetTop(); + int right = insets.getSystemWindowInsetRight(); + int bottom = insets.getSystemWindowInsetBottom(); + // **Two separate answers, because they are separate questions** + // (Iris's phone, 2026-09-06: "message box does not push up the + // scroll area"). `isVisible(ime())` says whether the keyboard is + // up; `getInsets(ime()).bottom` says how tall it is. An earlier + // pass sent the boolean *as* the height (0 or 1) because under + // plain `adjustResize` the window shrinks to make room and the ime + // inset therefore measures a zero overlap by construction -- true + // then, and no longer true now that this is an edge-to-edge window + // (`targetSdk` 35+, plus the `setDecorFitsSystemWindows` call + // above for the devices below that), which is exactly the case + // where the system stops resizing and hands the app the real + // overlap instead. Sending 1 for it left the Rust side padding the + // composer by one physical pixel, so the keyboard covered the bar + // and the transcript alike. + // + // The visibility is still sent in its own right rather than + // inferred from `height > 0`: the two disagree during the + // keyboard's slide-in and -out (visible, height still climbing), + // and "is the IME up" drives the bench's own state machine + // (`bench_client.rs`'s `ime_state`) where a half-open frame + // reading as "closed" is a miscount. + int imeBottom = 0; + int imeVisible = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; + imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0; + } + view.applyWindowInsets(left, top, right, bottom, imeBottom, imeVisible); + } } diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index 212b2c7..a875fba 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -574,10 +574,15 @@ impl BenchClient { Some(stats) => format!("{stats}"), None => "no frames recorded yet".to_string(), }; - match &self.android_state().renderer { + let renderer = match &self.android_state().renderer { Some(renderer) => renderer.diagnostics_report(&font, &frame_report), None => "iris diagnostics: no renderer yet (no surface)".to_string(), - } + }; + // The insets line goes in the pane, not just the log: Iris has no + // logcat on her phone, and "the keyboard does not push the + // composer up" cannot be told from "the listener never fired" + // without it (`AndroidUiState::insets_report`). + format!("{renderer}\n{}", self.android_state().insets_report()) } /// The keyboard's own diagnostics capture -- see `on_insets_changed`'s diff --git a/iris/benches/fling_spline_reference.py b/iris/benches/fling_spline_reference.py new file mode 100644 index 0000000..a62508b --- /dev/null +++ b/iris/benches/fling_spline_reference.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""AOSP's fling spline, transcribed independently of the Rust port. + +This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table` +and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code +grading its own homework. Every test iris's fling had before 2026-09-07 +compared the curve with itself -- monotonic, signed, integrates to the closed +form -- and all of them passed while `distance_fraction(t)` was returning +exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a +test have to come from somewhere else, and this is the somewhere else. + +Transcribed by hand from, and only from: + + * frameworks/base `core/java/android/widget/OverScroller.java`, + `SplineOverScroller`'s static initialiser, `getSplineDeceleration`, + `getSplineFlingDistance`, `getSplineFlingDuration` and `update`. + * androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt` + (`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and + `FlingCalculator.kt` (`computeDeceleration`, `flingDistance`, + `flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for + line, which is why iris ports one curve rather than two. + +Run it with no arguments; it prints the table entries and the (velocity, +density, t) points the Rust tests assert on. +""" + +NB_SAMPLES = 100 +INFLEXION = 0.35 +START_TENSION = 0.5 +END_TENSION = 1.0 +P1 = START_TENSION * INFLEXION +P2 = 1.0 - END_TENSION * (1.0 - INFLEXION) + +# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own +# "look and feel tuning" constant -- a different number in a different place +# of the same formula, which is the pair iris got the wrong way round once. +SCROLL_FRICTION = 0.015 +TUNING = 0.84 +GRAVITY_EARTH = 9.80665 +INCHES_PER_METER = 39.37 + +import math + +DECELERATION_RATE = math.log(0.78) / math.log(0.9) + + +def spline_positions(): + """SPLINE_POSITION: distance fraction at each of 101 even time steps.""" + position = [0.0] * (NB_SAMPLES + 1) + x_min = 0.0 + for i in range(NB_SAMPLES): + alpha = i / NB_SAMPLES + x_max = 1.0 + while True: + x = x_min + (x_max - x_min) / 2.0 + coef = 3.0 * x * (1.0 - x) + # Solved on the P1/P2 curve... + tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x + if abs(tx - alpha) < 1e-5: + break + if tx > alpha: + x_max = x + else: + x_min = x + # ...and sampled on the tension curve. + position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x + position[NB_SAMPLES] = 1.0 + return position + + +POSITION = spline_positions() + + +def fling_sample(t): + """(distance fraction, velocity fraction) at time fraction `t`.""" + t = min(max(t, 0.0), 1.0) + index = int(t * NB_SAMPLES) + if index >= NB_SAMPLES: + return 1.0, 0.0 + t_inf = index / NB_SAMPLES + t_sup = (index + 1) / NB_SAMPLES + velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf) + return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef + + +def physical_coefficient(density): + return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING + + +def deceleration(velocity, density): + return math.log( + INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density)) + ) + + +def fling_distance(velocity, density): + l = deceleration(velocity, density) + return ( + SCROLL_FRICTION + * physical_coefficient(density) + * math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l) + ) + + +def fling_duration_s(velocity, density): + l = deceleration(velocity, density) + return math.exp(l / (DECELERATION_RATE - 1.0)) + + +def position_at(velocity, density, t_seconds): + d = fling_duration_s(velocity, density) + return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0] + + +def velocity_at(velocity, density, t_seconds): + d = fling_duration_s(velocity, density) + return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d + + +if __name__ == "__main__": + print("SPLINE_POSITION at a few indices (index: value)") + for i in (0, 1, 10, 25, 50, 75, 99, 100): + print(f" {i:3}: {POSITION[i]:.6f}") + print() + print("distance/velocity fraction at time fractions") + for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0): + d, v = fling_sample(t) + print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}") + print() + # 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md); + # 2.75 is this checkout's emulator. + for density in (2.55, 2.75): + for velocity in (5000.0, 11064.0): + dur = fling_duration_s(velocity, density) + print( + f"density={density} v={velocity}: " + f"distance={fling_distance(velocity, density):.3f}px " + f"duration={dur:.4f}s" + ) + # Deliberately not round fractions. The velocity coefficient is + # piecewise *constant* across each of the 100 samples, so it + # steps at t = k/100 and a test asserting on 0.75 is asserting + # on which side of a discontinuity the last float landed -- + # which is genuinely different between Python and Rust and says + # nothing about the curve. + for frac in (0.125, 0.335, 0.505, 0.755): + t = frac * dur + print( + f" t={frac:>4} of duration ({t:.4f}s): " + f"pos={position_at(velocity, density, t):.3f}px " + f"vel={velocity_at(velocity, density, t):.3f}px/s" + ) diff --git a/iris/src/android/insets.rs b/iris/src/android/insets.rs index 98dc5b2..95ae6cb 100644 --- a/iris/src/android/insets.rs +++ b/iris/src/android/insets.rs @@ -65,6 +65,18 @@ pub struct Insets { #[derive(Default)] pub struct Shared { pub insets: Insets, + /// How many times Java has called `applyWindowInsetsNative` for this + /// peer, whether or not the numbers changed. Deliberately **not** a + /// field of `Insets`, which is compared for equality each frame to + /// decide whether to re-run `on_insets_changed`; a counter in there + /// would make every dispatch look like a change. + /// + /// It exists because "the keyboard does not push anything up" has two + /// completely different causes that look identical on screen -- the + /// listener never fired, or it fired with a zero `ime_bottom` -- and + /// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is + /// in the `Diagnostics` overlay, so one screenshot separates them. + pub updates: u64, } type SharedMap = HashMap>>>; @@ -102,7 +114,8 @@ extern "system" fn apply_window_insets<'local>( ime_visible: jint, ) { if let Some(shared) = map().lock().unwrap().get(&peer) { - shared.borrow_mut().insets = Insets { + let mut shared = shared.borrow_mut(); + shared.insets = Insets { left, top, right, @@ -110,6 +123,7 @@ extern "system" fn apply_window_insets<'local>( ime_bottom, ime_visible: ime_visible != 0, }; + shared.updates += 1; } // Insets can change (the keyboard opening) with no resize and no // touch, so nothing else here would otherwise ask for a frame. diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index 391cf76..42ea587 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -130,6 +130,28 @@ impl AndroidUiState { pub fn insets(&self) -> Insets { self.shared.borrow().insets } + + /// The insets state as one line for a diagnostics pane, including how + /// many times the platform has delivered any -- see + /// `insets::Shared::updates` for why the count is the load-bearing + /// part. `dispatches=0` says the listener has never run and the + /// numbers beside it are defaults rather than measurements, which is + /// the distinction a screenshot otherwise cannot make (UI_RULES.md, + /// "design the unknown state first"). + pub fn insets_report(&self) -> String { + let shared = self.shared.borrow(); + let i = shared.insets; + if shared.updates == 0 { + return "insets: dispatches=0 -- the platform has never called \ + onApplyWindowInsets, so nothing below was measured" + .to_string(); + } + format!( + "insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \ + ime_visible={}", + shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible, + ) + } } impl HasRoot for AndroidUiState { diff --git a/iris/src/sense.rs b/iris/src/sense.rs index 4d31d62..95f3cd9 100644 --- a/iris/src/sense.rs +++ b/iris/src/sense.rs @@ -921,21 +921,38 @@ impl VelocityTracker { /// Android's fling deceleration curve, ported from AOSP's /// `android.widget.OverScroller.SplineOverScroller` (the same curve -/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and -/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a -/// fling here travels the same distance a Compose `LazyColumn`'s own +/// Compose's `androidx.compose.animation.AndroidFlingSpline` and +/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here +/// travels the same distance a Compose `LazyColumn`'s own /// `ScrollableDefaults.flingBehavior()` would for the same initial /// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling /// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum" /// asked for the same physics a reader's muscle memory already expects -/// from every other Android scroll view. +/// from every other Android scroll view. Both sources were read at +/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and +/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` +/// (2026-09-07); they agree line for line. /// -/// The curve is a cubic-Bezier-derived spline sampled into two lookup -/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]): -/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total -/// distance/time elapsed at the `i`th of 100 even steps along the curve's -/// own parameter. A lookup at an arbitrary time fraction interpolates -/// between the two bracketing samples. +/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]` +/// is the fraction of the total distance covered at time fraction +/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and +/// `(index + 1) / N` -- never between table entries. AOSP builds a second +/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling +/// whose target moved), which nothing here has; it is deliberately not +/// built, so there is one array and one indexing rule rather than two of +/// each to pick the wrong one from. +/// +/// The wrong one was picked, and this is what it cost. Until 2026-09-07 +/// the two halves of AOSP's build loop were transposed -- the bisection +/// solved the tension curve and the sample evaluated the `P1`/`P2` one, +/// where AOSP does the opposite -- which made this table and `SPLINE_TIME` +/// *identical*, and the old lookup, which bracketed `t` between +/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling +/// coasted at constant speed for its whole duration and stopped dead: +/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt +/// stop", verbatim out of the arithmetic. Every test it had compared the +/// curve with itself, so none of them could see it; +/// `the_spline_matches_aosps_own_table` pins the absolute numbers now. mod android_fling_spline { use std::sync::OnceLock; @@ -948,24 +965,30 @@ mod android_fling_spline { const P1: f32 = START_TENSION * INFLEXION; const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); - pub(super) struct Spline { - position: [f32; NB_SAMPLES + 1], - time: [f32; NB_SAMPLES + 1], + /// What a lookup answers: how far along the fling is, and how fast it + /// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's + /// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's + /// *total* distance, the second per unit of its *total* duration, so a + /// caller scales them by `distance` and `distance / duration`. + pub(super) struct SplineSample { + pub(super) distance_fraction: f32, + pub(super) velocity_fraction: f32, } - fn build() -> Spline { + fn build() -> [f32; NB_SAMPLES + 1] { let mut position = [0.0f32; NB_SAMPLES + 1]; - let mut time = [0.0f32; NB_SAMPLES + 1]; - let (mut x_min, mut y_min) = (0.0f32, 0.0f32); - for i in 0..NB_SAMPLES { + let mut x_min = 0.0f32; + for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) { let alpha = i as f32 / NB_SAMPLES as f32; - let mut x_max = 1.0f32; let (mut x, mut coef); loop { x = x_min + (x_max - x_min) / 2.0; coef = 3.0 * x * (1.0 - x); - let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x; + // Solved on the `P1`/`P2` curve and sampled on the tension + // one. Transposing these two is the defect this module's + // doc comment describes; they are not interchangeable. + let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; if (tx - alpha).abs() < 1e-5 { break; } @@ -975,50 +998,37 @@ mod android_fling_spline { x_min = x; } } - position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; - - let mut y_max = 1.0f32; - let (mut y, mut coef_y); - loop { - y = y_min + (y_max - y_min) / 2.0; - coef_y = 3.0 * y * (1.0 - y); - let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y; - if (dy - alpha).abs() < 1e-5 { - break; - } - if dy > alpha { - y_max = y; - } else { - y_min = y; - } - } - time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y; + *slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x; } position[NB_SAMPLES] = 1.0; - time[NB_SAMPLES] = 1.0; - Spline { position, time } + position } - static SPLINE: OnceLock = OnceLock::new(); + static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new(); - /// The fraction of total distance covered at `time_fraction` (0..=1 - /// of the fling's total duration). Finds the bracketing samples in - /// `SPLINE_TIME` and interpolates linearly between their matching - /// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller - /// .flingPosition` does. - pub(super) fn distance_fraction(time_fraction: f32) -> f32 { - let spline = SPLINE.get_or_init(build); + /// Sample the curve at `time_fraction` (0..=1 of the fling's total + /// duration), exactly as AOSP's `SplineOverScroller.update` and + /// Compose's `AndroidFlingSpline.flingPosition` do. + pub(super) fn sample(time_fraction: f32) -> SplineSample { + let position = SPLINE_POSITION.get_or_init(build); let t = time_fraction.clamp(0.0, 1.0); - let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1); - let t_inf = spline.time[index]; - let t_sup = spline.time[index + 1]; - let d_inf = spline.position[index]; - let d_sup = spline.position[index + 1]; - let span = t_sup - t_inf; - if span <= 0.0 { - d_inf - } else { - d_inf + (d_sup - d_inf) * (t - t_inf) / span + let index = (t * NB_SAMPLES as f32) as usize; + if index >= NB_SAMPLES { + // The end of the fling: all of the distance covered and + // nothing left moving. AOSP's `distanceCoef = 1f` / + // `velocityCoef = 0f` defaults, which its + // `if (index < NB_SAMPLES)` leaves in place. + return SplineSample { + distance_fraction: 1.0, + velocity_fraction: 0.0, + }; + } + let t_inf = index as f32 / NB_SAMPLES as f32; + let t_sup = (index + 1) as f32 / NB_SAMPLES as f32; + let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf); + SplineSample { + distance_fraction: position[index] + (t - t_inf) * velocity_fraction, + velocity_fraction, } } } @@ -1106,17 +1116,35 @@ impl FlingCalculator { } /// The signed distance covered by `elapsed` into a fling of this - /// `velocity` that started at `t0` -- what a per-frame ticker - /// (`List::tick_fling`) calls to find how far to have scrolled by now. - /// Clamped to the full `distance()` once `elapsed` reaches - /// `duration()`, so a caller need not special-case "past the end." + /// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to + /// find how far to have scrolled by now. Clamped to the full + /// `distance()` once `elapsed` reaches `duration()`, so a caller need + /// not special-case "past the end." pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 { let duration = self.duration(velocity); if duration.is_zero() { return 0.0; } - let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0); - self.distance(velocity) * android_fling_spline::distance_fraction(fraction) + let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); + self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction + } + + /// The signed *speed* at `elapsed` into the same fling, in the units + /// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's + /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start + /// to zero at `duration()`, which is the whole difference between a + /// fling and a constant-speed slide, so it is what + /// `List::tick_fling`'s debug line reports: successive frames printing + /// a shrinking number is the evidence that the curve is being followed + /// at all. + pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { + let duration = self.duration(velocity); + if duration.is_zero() { + return 0.0; + } + let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); + android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity) + / duration.as_secs_f32() } } @@ -1281,6 +1309,93 @@ mod fling_calculator_tests { total ); } + + /// The table itself, against AOSP's own entries. Every number here + /// came out of `benches/fling_spline_reference.py`, which is a + /// separate hand transcription of `OverScroller.java` and + /// `SplineBasedDecay.kt` -- so this is the one test in the file that + /// is not the Rust code grading its own homework, and the only kind + /// that could have caught the transposed build loop + /// `android_fling_spline`'s doc describes. + /// + /// The property that names the old defect directly: the curve is + /// **not** the identity. At a tenth of the way through its time a + /// fling has covered 27.4% of its distance, and at half its time + /// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed + /// slide -- so the two `assert!`s below fail by a factor of three. + #[test] + fn the_spline_matches_aosps_own_table() { + for (t, expected) in [ + (0.0f32, 0.000023f32), + (0.1, 0.274002), + (0.25, 0.583811), + (0.5, 0.858411), + (0.75, 0.971068), + (0.9, 0.995811), + (1.0, 1.0), + ] { + let got = android_fling_spline::sample(t).distance_fraction; + assert!( + (got - expected).abs() < 1e-4, + "distance fraction at t={t}: got {got}, AOSP says {expected}" + ); + } + // Speed falls monotonically to nothing -- the difference between + // a fling and a slide, and what the abrupt stop was. + let mut last = f32::INFINITY; + for step in 0..=100 { + let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction; + assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}"); + last = v; + } + assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0); + } + + /// The same curve carried through `distance`/`duration` at Iris's own + /// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`), + /// again with every number from `benches/fling_spline_reference.py`. + /// A fling's *speed* a third of the way through is 4733px/s out of an + /// initial 11064 -- what a reader sees as deceleration, and the + /// quantity that was constant before this. + /// + /// The sample fractions are deliberately not round: the velocity + /// coefficient is piecewise constant across each of the 100 samples, + /// so `0.75` sits exactly on a step and the assertion would be about + /// which side of it the last float landed rather than about the curve. + #[test] + fn a_flick_decelerates_the_way_aosp_says_it_does() { + let calc = FlingCalculator::new(2.55); + let velocity = 11064.0f32; + let duration = calc.duration(velocity); + assert!( + (duration.as_secs_f32() - 1.6357).abs() < 0.01, + "duration {duration:?}" + ); + assert!( + (calc.distance(velocity) - 6334.2).abs() < 5.0, + "distance {}", + calc.distance(velocity) + ); + for (fraction, position, speed) in [ + (0.125f32, 2123.3f32, 9202.1f32), + (0.335, 4458.3, 4733.0), + (0.505, 5459.0, 2649.6), + (0.755, 6158.7, 950.9), + ] { + let at = duration.mul_f32(fraction); + let got_position = calc.position_at(velocity, at); + let got_speed = calc.velocity_at(velocity, at); + assert!( + (got_position - position).abs() < 5.0, + "position at {fraction} of the fling: got {got_position}, AOSP says {position}" + ); + assert!( + (got_speed - speed).abs() < 20.0, + "speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}" + ); + } + assert_eq!(calc.velocity_at(velocity, duration), 0.0); + } } #[cfg(test)] diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 50f758e..e64bf32 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -434,11 +434,12 @@ impl List { /// has no idea a finger came back down, and Android's own `Scroller` /// relies on the view calling `abortAnimation` for the same reason. /// - /// Density cancels out of the underlying spline as long as velocity - /// and the distance it produces share one pixel space (see - /// `FlingCalculator`'s own doc) -- `List` works entirely in logical - /// pixels, so `1.0` here is not a placeholder for "unknown density," - /// it is the correct density for a self-consistent unit system. + /// The density handed to `FlingCalculator` is this list's own + /// (`self.density`, taken from the painter in `draw`), not `1.0`: it + /// does **not** cancel out of the spline -- see `FlingCalculator`'s + /// doc, which used to claim the opposite, and the 45-second coast that + /// claim produced. + /// /// **Sets the fling; it does not drive it.** A fling moves only while /// something calls [`Self::tick_fling`] once per frame, and what does /// that in a running app is `UiData::tick_animations`, over the ids @@ -514,6 +515,20 @@ impl List { f.applied = target; let settled_on_schedule = elapsed >= f.calc.duration(f.velocity); let velocity = f.velocity; + // The evidence that the spline is actually being followed, at the + // one granularity where a linear coast and a decelerating one look + // different: successive `dy` and `speed` shrinking. It was neither + // observable nor observed while `distance_fraction` returned `t` + // (`android_fling_spline`'s doc), which is why this is here rather + // than the total-travel line the release log already carries. + log::debug!( + "iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px", + elapsed.as_secs_f32(), + delta, + f.calc.velocity_at(velocity, elapsed), + velocity, + f.calc.distance(velocity) - target, + ); self.scroll(delta); // Clamp: a fling moving toward the start that has already reached @@ -1768,6 +1783,19 @@ mod tests { w[1] ); } + // Non-increasing is not deceleration: a fling that coasts at a + // constant speed and then stops dead satisfies every `<=` above, + // and that is exactly what iris shipped until 2026-09-07 + // (`android_fling_spline`'s doc). Over the samples collected here + // -- the earliest part of the curve, since row 0 leaves the loaded + // extents soon after -- AOSP's spline has already lost more than + // a fifth of its speed. + let (first, last) = (deltas[1], *deltas.last().unwrap()); + assert!( + last < first * 0.8, + "fling barely slowed across {} ticks: {first} -> {last}", + deltas.len() + ); } #[test]