iris: the fling curve was the identity function, and the keyboard was a targetSdk
Iris's 2026-09-07 phone report on ed04d4c: the resume glyph corruption is
fixed (item 4 closed with her evidence), flinging "seems to just be linear
velocity with an abrupt stop", and the keyboard still does not push
anything up. docs/RUST.md's new "The 2026-09-07 phone report" section has
the derivation and every number.
**The fling was arithmetically linear.** `android_fling_spline::
distance_fraction(t)` returned `t` for every `t`. Two halves of AOSP's
`SplineOverScroller` static initialiser had been transposed -- the
bisection solved the tension curve and the sample evaluated the P1/P2 one,
where AOSP does the opposite -- which made SPLINE_POSITION and SPLINE_TIME
identical; the lookup then bracketed `t` between SPLINE_TIME entries
instead of between even time steps, and the two cancelled to the identity.
Ported exactly now from OverScroller.java and androidx.compose.animation
1.12.0's SplineBasedDecay.kt, which agree line for line, as one table
indexed by even steps of time (AOSP's second table serves only
`adjustDuration`, which nothing here has, so it is deliberately not built
-- one array, one indexing rule). `FlingCalculator::velocity_at` is new
beside `position_at`, and `List::tick_fling` logs `iris fling tick:` with
the per-frame delta and speed.
Every existing test compared the calculator with itself -- monotonic,
signed, integrates to the closed form, deltas non-increasing -- and all of
them pass on a straight line. iris/benches/fling_spline_reference.py is an
independent hand transcription of both sources and supplies the numbers
now checked into `the_spline_matches_aosps_own_table` and
`a_flick_decelerates_the_way_aosp_says_it_does`;
`tick_fling_applies_shrinking_incremental_deltas` went from
"non-increasing" to "the last delta is under 80% of the first". Negative
control: with `sample` forced back to `t`, exactly those three fail.
Emulator (API 36, debug, force-gles): a released v=3750 decelerates
3746 -> 2624 -> 1834 -> 1144 -> 752 -> 449 -> 243 -> 83px/s over 32 frames
to t=0.664s; 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.
**The keyboard: `targetSdk = 34`** in iris/android-app/app/build.gradle,
against compileSdk 37 and the Compose app's 37 -- and that app's keyboard
does push up on her phone. Below target 35 a window keeps the legacy
behaviour where adjustResize shrinks it for the IME, so
getInsets(ime()).bottom measures an already-shrunk window and is 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.
That is a reading and not a measurement, so the other half is making the
phone able to answer it. MainActivity also registers a
WindowInsetsAnimation.Callback (onEnd re-reads getRootWindowInsets, so an
interrupted animation cannot freeze a value), which delivers the height
where only the animation path carries it and makes the push-up animate:
ime_bottom now arrives 509, 663, 833, 881, 883 instead of one jump.
`insets::Shared::updates` counts every dispatch and
`AndroidUiState::insets_report()` puts it in the Diagnostics pane --
screenshot-verified, `insets: dispatches=27 left=0 top=142 right=0
bottom=63 ime_bottom=0 ime_visible=false`. Iris has no logcat, and "the
listener never fired" and "it fired with a zero height" are otherwise the
same picture; dispatches=0 says so in words rather than showing defaults.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f4ea7e8fd
commit
73f956f8e0
11 files changed
+720
-120
No files matched your search
@@ -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
|
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.
|
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
|
## 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
|
Iris's phone said "fling still doesn't work" twice. The velocity was only
|
||||||
|
|||||||
+50
-17
@@ -351,13 +351,27 @@ agent ticks it here with the evidence.
|
|||||||
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
Iris's report, verbatim, with a screenshot. Phone: Mali-G715 (Vulkan),
|
||||||
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
`content_scale: 2.55`, 120Hz. Open until ticked with phone-side evidence.
|
||||||
|
|
||||||
- [ ] **"Fling still doesn't work."** *(Three defects fixed 2026-09-07;
|
- [ ] **"Fling still doesn't work."** -> on `ed04d4c`, 2026-09-07:
|
||||||
open until the phone says so. **The line to look for:**
|
*"flinging now does technically do something, but it seems to just be
|
||||||
`adb logcat | grep "iris drag release"` -- `samples=1` or `span=0.0ms`
|
linear velocity with an abrupt stop."* **It was exactly that, and the
|
||||||
means the batched samples are not reaching the tracker there, while a
|
arithmetic said so.** `distance_fraction(t)` returned `t` for every `t`
|
||||||
sensible span with `v=` in the thousands and
|
-- a constant-speed slide for the whole duration, then a stop at full
|
||||||
`outcome=Released(Some(…))` means the gesture was measured right and
|
distance -- because two halves of AOSP's spline build loop were
|
||||||
anything still wrong is downstream.)* Second report; the emulator's
|
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),
|
`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
|
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
|
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
|
`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
|
(`FocusHost`: a tap on a focused field requests the IME, idempotent
|
||||||
when it is already shown).
|
when it is already shown).
|
||||||
- [x] **"Message box does not push up the scroll area."** *(Fixed
|
- [ ] **"Message box does not push up the scroll area."**
|
||||||
2026-09-07: height and visibility are two JNI values now. Emulator:
|
**Reopened by the phone on 2026-09-07** -- *"similarly, the keyboard
|
||||||
`iris insets: … bottom=883 ime_bottom=883 ime_visible=true`, composer
|
raising up does not push things upwards"* -- after being ticked on
|
||||||
box `31,2277..1048,2329` -> `31,1457..1048,1509`, and the list follows
|
emulator evidence the day before (`ime_bottom=883`, composer box
|
||||||
because it is `rest(1)` in the same `Span`.)* Since
|
`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
|
`MainActivity` went edge-to-edge (`e12c708`), `adjustResize` no
|
||||||
longer resizes the window, so the app owns the IME inset -- but
|
longer resizes the window, so the app owns the IME inset -- but
|
||||||
`ime_bottom` is passed through JNI as the boolean `1`/`0` (the
|
`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
|
screenshotted the emulator's GLES path, where a resume may not
|
||||||
destroy the surface at all.
|
destroy the surface at all.
|
||||||
|
|
||||||
**Fixed in `ba2afba`, with `clearing_the_atlas_re_renders_cached_text_
|
**Fixed in `ba2afba` and confirmed on the phone (Iris, 2026-09-07:
|
||||||
instead_of_reusing_it` run and passing (2026-09-07). Ticked on the
|
"the resume glyph corruption is fixed").** Closed. The emulator could
|
||||||
code; still wants phone-side confirmation** -- no emulator here has a
|
never have settled it -- no Vulkan adapter here, and the GLES path may
|
||||||
Vulkan adapter, and the GLES path may not destroy the surface at all,
|
not destroy the surface at all -- so the phone was the only place this
|
||||||
so the emulator cannot reproduce the state Iris photographed.
|
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
|
The reading above is right and the mechanism is one step narrower than
|
||||||
"cached text primitives". `IrisViewPeer::surface_changed`
|
"cached text primitives". `IrisViewPeer::surface_changed`
|
||||||
|
|||||||
+123
@@ -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
|
a phone-shaped window and screenshots it. Record the commands here when
|
||||||
it lands.
|
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
|
### 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,
|
Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,
|
||||||
|
|||||||
@@ -22,7 +22,28 @@ android {
|
|||||||
// untested fallback path is its own defect. `build-apk.sh`'s
|
// untested fallback path is its own defect. `build-apk.sh`'s
|
||||||
// `cargo ndk -P` is kept at the same number.
|
// `cargo ndk -P` is kept at the same number.
|
||||||
minSdk = 29
|
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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import android.app.Activity;
|
|||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
|
import android.view.WindowInsetsAnimation;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The android-view backend's demo activity (RUST.md's I2): one IrisView
|
* 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);
|
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<WindowInsetsAnimation> 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) -> {
|
view.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||||
int left = insets.getSystemWindowInsetLeft();
|
sendInsets((IrisView) v, insets);
|
||||||
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);
|
|
||||||
return 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -574,10 +574,15 @@ impl BenchClient {
|
|||||||
Some(stats) => format!("{stats}"),
|
Some(stats) => format!("{stats}"),
|
||||||
None => "no frames recorded yet".to_string(),
|
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),
|
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
|
||||||
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
|
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
|
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -65,6 +65,18 @@ pub struct Insets {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Shared {
|
pub struct Shared {
|
||||||
pub insets: Insets,
|
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<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
@@ -102,7 +114,8 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
ime_visible: jint,
|
ime_visible: jint,
|
||||||
) {
|
) {
|
||||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
shared.borrow_mut().insets = Insets {
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
left,
|
left,
|
||||||
top,
|
top,
|
||||||
right,
|
right,
|
||||||
@@ -110,6 +123,7 @@ extern "system" fn apply_window_insets<'local>(
|
|||||||
ime_bottom,
|
ime_bottom,
|
||||||
ime_visible: ime_visible != 0,
|
ime_visible: ime_visible != 0,
|
||||||
};
|
};
|
||||||
|
shared.updates += 1;
|
||||||
}
|
}
|
||||||
// Insets can change (the keyboard opening) with no resize and no
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
// touch, so nothing else here would otherwise ask for a frame.
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ impl AndroidUiState {
|
|||||||
pub fn insets(&self) -> Insets {
|
pub fn insets(&self) -> Insets {
|
||||||
self.shared.borrow().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 {
|
impl HasRoot for AndroidUiState {
|
||||||
|
|||||||
+178
-63
@@ -921,21 +921,38 @@ impl VelocityTracker {
|
|||||||
|
|
||||||
/// Android's fling deceleration curve, ported from AOSP's
|
/// Android's fling deceleration curve, ported from AOSP's
|
||||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
/// Compose's `androidx.compose.animation.AndroidFlingSpline` and
|
||||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here
|
||||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
/// travels the same distance a Compose `LazyColumn`'s own
|
||||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
/// 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"
|
/// 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
|
/// 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
|
/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]`
|
||||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
/// is the fraction of the total distance covered at time fraction
|
||||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and
|
||||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
/// `(index + 1) / N` -- never between table entries. AOSP builds a second
|
||||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling
|
||||||
/// between the two bracketing samples.
|
/// 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 {
|
mod android_fling_spline {
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -948,24 +965,30 @@ mod android_fling_spline {
|
|||||||
const P1: f32 = START_TENSION * INFLEXION;
|
const P1: f32 = START_TENSION * INFLEXION;
|
||||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||||
|
|
||||||
pub(super) struct Spline {
|
/// What a lookup answers: how far along the fling is, and how fast it
|
||||||
position: [f32; NB_SAMPLES + 1],
|
/// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's
|
||||||
time: [f32; NB_SAMPLES + 1],
|
/// `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 position = [0.0f32; NB_SAMPLES + 1];
|
||||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
let mut x_min = 0.0f32;
|
||||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) {
|
||||||
for i in 0..NB_SAMPLES {
|
|
||||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||||
|
|
||||||
let mut x_max = 1.0f32;
|
let mut x_max = 1.0f32;
|
||||||
let (mut x, mut coef);
|
let (mut x, mut coef);
|
||||||
loop {
|
loop {
|
||||||
x = x_min + (x_max - x_min) / 2.0;
|
x = x_min + (x_max - x_min) / 2.0;
|
||||||
coef = 3.0 * x * (1.0 - x);
|
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 {
|
if (tx - alpha).abs() < 1e-5 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -975,50 +998,37 @@ mod android_fling_spline {
|
|||||||
x_min = x;
|
x_min = x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
*slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + 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;
|
|
||||||
}
|
}
|
||||||
position[NB_SAMPLES] = 1.0;
|
position[NB_SAMPLES] = 1.0;
|
||||||
time[NB_SAMPLES] = 1.0;
|
position
|
||||||
Spline { position, time }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new();
|
||||||
|
|
||||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
/// Sample the curve at `time_fraction` (0..=1 of the fling's total
|
||||||
/// of the fling's total duration). Finds the bracketing samples in
|
/// duration), exactly as AOSP's `SplineOverScroller.update` and
|
||||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
/// Compose's `AndroidFlingSpline.flingPosition` do.
|
||||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
pub(super) fn sample(time_fraction: f32) -> SplineSample {
|
||||||
/// .flingPosition` does.
|
let position = SPLINE_POSITION.get_or_init(build);
|
||||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
|
||||||
let spline = SPLINE.get_or_init(build);
|
|
||||||
let t = time_fraction.clamp(0.0, 1.0);
|
let t = time_fraction.clamp(0.0, 1.0);
|
||||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
let index = (t * NB_SAMPLES as f32) as usize;
|
||||||
let t_inf = spline.time[index];
|
if index >= NB_SAMPLES {
|
||||||
let t_sup = spline.time[index + 1];
|
// The end of the fling: all of the distance covered and
|
||||||
let d_inf = spline.position[index];
|
// nothing left moving. AOSP's `distanceCoef = 1f` /
|
||||||
let d_sup = spline.position[index + 1];
|
// `velocityCoef = 0f` defaults, which its
|
||||||
let span = t_sup - t_inf;
|
// `if (index < NB_SAMPLES)` leaves in place.
|
||||||
if span <= 0.0 {
|
return SplineSample {
|
||||||
d_inf
|
distance_fraction: 1.0,
|
||||||
} else {
|
velocity_fraction: 0.0,
|
||||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
};
|
||||||
|
}
|
||||||
|
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
|
/// The signed distance covered by `elapsed` into a fling of this
|
||||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
/// `velocity` -- what a per-frame ticker (`List::tick_fling`) calls to
|
||||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
/// find how far to have scrolled by now. Clamped to the full
|
||||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
/// `distance()` once `elapsed` reaches `duration()`, so a caller need
|
||||||
/// `duration()`, so a caller need not special-case "past the end."
|
/// not special-case "past the end."
|
||||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||||
let duration = self.duration(velocity);
|
let duration = self.duration(velocity);
|
||||||
if duration.is_zero() {
|
if duration.is_zero() {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
let fraction = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
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
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+33
-5
@@ -434,11 +434,12 @@ impl List {
|
|||||||
/// has no idea a finger came back down, and Android's own `Scroller`
|
/// has no idea a finger came back down, and Android's own `Scroller`
|
||||||
/// relies on the view calling `abortAnimation` for the same reason.
|
/// relies on the view calling `abortAnimation` for the same reason.
|
||||||
///
|
///
|
||||||
/// Density cancels out of the underlying spline as long as velocity
|
/// The density handed to `FlingCalculator` is this list's own
|
||||||
/// and the distance it produces share one pixel space (see
|
/// (`self.density`, taken from the painter in `draw`), not `1.0`: it
|
||||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
/// does **not** cancel out of the spline -- see `FlingCalculator`'s
|
||||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
/// doc, which used to claim the opposite, and the 45-second coast that
|
||||||
/// it is the correct density for a self-consistent unit system.
|
/// claim produced.
|
||||||
|
///
|
||||||
/// **Sets the fling; it does not drive it.** A fling moves only while
|
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||||
/// something calls [`Self::tick_fling`] once per frame, and what does
|
/// something calls [`Self::tick_fling`] once per frame, and what does
|
||||||
/// that in a running app is `UiData::tick_animations`, over the ids
|
/// that in a running app is `UiData::tick_animations`, over the ids
|
||||||
@@ -514,6 +515,20 @@ impl List {
|
|||||||
f.applied = target;
|
f.applied = target;
|
||||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||||
let velocity = 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);
|
self.scroll(delta);
|
||||||
|
|
||||||
// Clamp: a fling moving toward the start that has already reached
|
// Clamp: a fling moving toward the start that has already reached
|
||||||
@@ -1768,6 +1783,19 @@ mod tests {
|
|||||||
w[1]
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user