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>
153 lines
5.5 KiB
Python
153 lines
5.5 KiB
Python
#!/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"
|
|
)
|