Device testing on a tablet (SM-T220, Android 14) walked every text-field focus
path in the embedded tab. Two of them were wrong.
**The tab-return restore never fired.** `noteKeyboardOnLeave` sampled
`WindowInsets.imeAnimationTarget > 0 && isMirroringPageField()` inside
`onDispose`, on the assumption that the dispose runs before anything hides the
IME. It does not: by the time it runs, the nav transition has already snapped
the animation target to 0 *and* taken focus off the view, so both halves read
false and every tab was recorded as "left without a keyboard". Instrumented on
device, the leave was `keyboardUp=false mirroring=false imeBottomPx=0` for all
three nav-rail routes, so `pendingRestore` was false on every return and a tab
left mid-typing always came back with the keyboard down.
Ask the mirror what it *intends* instead of sampling the window at teardown:
`RemoteImeView.keyboardWanted` is set when we raise the keyboard and cleared
when the field blurs or the user puts the keyboard away, so it still reads true
while the view is being torn down.
Telling "user dismissed it" apart from "the tab went away" is what that clearing
needs, and there is no key hook for it — Android 13+ routes the IME's back
dismissal through OnBackInvokedCallback, so `onKeyPreIme` is never called (tried
first; it silently never fired and the tab over-restored). The two cases are
distinguishable by what else is true when the insets collapse, measured on
device:
dismiss: imeBottomPx=0 hasFocus=true mirrors=true
tab switch: imeBottomPx=0 hasFocus=false mirrors=false
so a collapse while we still mirror the field is the dismissal, and a switch
never looks like one — the focus loss lands in the same frame as the insets.
**A readonly field raised a keyboard that cannot type.** `isEditable` in the
shim never looked at `readOnly`, so the host took the field and showed a
keyboard whose keystrokes the page discards. Native, checked side by side in the
full-screen WebView on the same page, focuses a readonly field without a
keyboard. The field stays "editable" for selection (native offers handles and
Copy there); only the raise is suppressed, via one guard in `raiseKeyboard` so
the fresh-focus, tap-doorbell and tab-restore paths are all covered.
Verified on device, 27/27 checks: fresh focus raises for text/textarea/
contenteditable/email/number/password/search/tel and not for disabled or
readonly; BACK-dismiss then re-tap restores; re-tapping a field whose keyboard
is up keeps it; leaving mid-typing restores on return (~1s, 5/5 runs) while a
dismissed tab stays down; typing after either restore lands in the right field
at the right caret; page-background tap blurs; address-bar keyboard never arms
an embed restore; and the full-screen round trip leaves the embed IME working.
`tools/ime-test/keyboard.html` is the page those checks drive: every field type
plus a live focus readout and an event log that marks taps on an already-focused
field, which is the case with no DOM event of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IME / text-selection test harness
A single-file web page (index.html) for exercising and profiling the embedded
WebView IME + text-selection relay (see
amethyst/plans/2026-06-25-embed-text-selection-native-parity.md). It has a
plain <input> and a <textarea> plus an on-page green log that records, with
millisecond timestamps:
- focus/blur,
selectionchange,keydown/beforeinput/input, composition events, and the resultingvalue/selection — to catch erase, caret-jump, and focus-transfer regressions; - paint latency (
requestAnimationFrameafter each DOM change) — the metric that exposed the first-letter freeze; - long-task + main-thread-block detectors and a focus/selection heartbeat — to catch anything stalling the WebView main thread or spontaneously moving focus/selection.
The log lines are tagged [ImeDiag] and also go to console.log, so they show
up in adb logcat (the :napplet process owns the WebView console). Nothing
here ships in the app — it's a dev tool, which is why the [ImeDiag] strings
live only under tools/.
Run it
-
Serve this directory over HTTP from your dev machine:
cd tools/ime-test && python3 -m http.server 8765 -
Reach it from the device/emulator:
- Emulator: the page is at
http://10.0.2.2:8765(10.0.2.2is the emulator's alias for the host loopback). - Physical device (USB):
adb reverse tcp:8765 tcp:8765, then the page is athttp://localhost:8765.
- Emulator: the page is at
-
Open that URL as an embedded tab (this is the path that uses the relay — not a full-screen activity):
- Open the in-app browser (
BrowserScreen) and type the URL into its address bar. The embedded browser handleshttp/https, so it loads into the:nappletSurfaceControlViewHost surface.
To compare against native behavior, open the same URL in a full-screen activity (where the WebView renders in-window with the native keyboard) — that is also how you reproduce the full-screen round-trip highlight bug (open full-screen,
back, then selection highlight is dead across all embeds). - Open the in-app browser (
Reading the log
INPUT … val=… sel=…right after a keystroke with the right value = no erase.PAINT-LATENCY Nmsspiking to ~1000ms = the first-letter freeze (should stay low now that the surface no longer resizes on IME show).MAINTHREAD BLOCKED/LONGTASK= something is stalling the WebView thread.HEARTBEATlines changing while idle = spontaneous focus/selection drift.
perf.html — why does the embed feel slower than the full-screen browser?
index.html profiles the IME relay. perf.html answers a different question:
the embedded tab and the full-screen browser are the same WebView in the same
:napplet process with byte-identical WebSettings, so when a site's JS feels
slower in the embed, the cause is host-induced — and this page measures which
host effect it is.
Serve the directory (above) and open the same URL in both hosts, then compare the summary line at the bottom of the page:
-
vis=hidden— decisive. Chromium considers the embedded page hidden, so it clamps timers to ~1Hz and suspendsrequestAnimationFrame. Everything the site schedules lands late; it reads as "the JS got slow". Confirmed bytimer50(a 50ms interval firing at 500-1000ms) andraf(0 fps). -
vis=visiblebutcpuis 2-4× the full-screen number — the process is running on the little cores. The site's JS runs in the WebView renderer process, whose scheduling class is inherited from its host::nappletistop-appwhen it fronts the full-screen activity, but only a bound service (BIND_AUTO_CREATE, noBIND_IMPORTANT) when it serves the embed. Cross-check off-device with:adb shell dumpsys activity processes | grep -E 'napplet|sandboxed' adb shell "cat /proc/$(adb shell pidof com.vitorpamplona.amethyst:napplet)/cgroup"Expect
/top-appwith the full-screen browser open and/foreground(or lower) with an embed tab open. -
cpumatches butinputDeliveryis much higher — the gap is input routing into the embedded window, not compute.inputDeliveryis the time between the platform stamping the touch and JS receiving it. -
layoutmuch higher in the embed — layout/paint is the bottleneck (check logcat for WebView software-rendering warnings; a non-hardware-acceleratedSurfaceControlViewHostwindow would put Chromium on the software path). -
focus=falsein the embed is expected and is not itself a throttle: the host window owns the keyboard, which is the whole reasonRemoteImeViewexists.
longtasks counts main-thread blocks over 50ms while the page was measuring —
high counts in the embed with a matching cpu number point at something else in
the process competing (e.g. parked warm tabs that are never paused).