Merge pull request #3378 from vitorpamplona/fix/embed-ime-selection

Embedded text selection: native-parity IME + selection UI + magnifier, and platform-bug fixes
This commit is contained in:
Vitor Pamplona
2026-06-26 11:08:42 -04:00
committed by GitHub
21 changed files with 2307 additions and 76 deletions
@@ -0,0 +1,256 @@
# Embedded text selection — native-Android parity
**Status:** core feature-complete. The working set landed in `fix(embed): IME typing +
host-drawn text selection for embedded surfaces` (commit `e0a2a9ab81`); subsequent
sessions added the magnifier, the `SelectionUiState` refactor, hybrid word+char
handle-extend, and a run of polish/bug fixes (below). **The one open platform bug —
full-screen round-trip kills selection paint — is now FIXED** (root cause was
process-global `pauseTimers()` + an attached `WebView.destroy()`; see that section).
**2026-06-26 fixes (branch `fix/embed-ime-selection`):**
- **No-blink word-select** — the overlay handles + toolbar blinked 23× on long-press
word-select; cause was the shim's selection-*reveal* scrolls (a `<textarea>` auto-
scrolling to show a forming/re-asserted range) tripping the hide-on-scroll path. The
shim now timestamps selection activity (`lastSelActivityAt`) and treats a scroll within
350 ms as a reveal-scroll (reposition, don't hide, don't re-arm the timer). Plus a
`RemoteImeView` range-lost debounce as a safety net. (#2, #3)
- **Page selection clears on field focus** — focusing a field left the page-text handles
+ Copy bar up (the shim's page `selectionchange` is muted once a field is focused), and
being z-above they STOLE the field handle's drag. Fixed: `focusin` emits `pagesel:false`
(+ resets the scroll state); host `ImeEvent.Focus` also drops the page overlay. (#2)
- **Caret handle drag** moved the loupe but not the caret — the unified `awaitEachGesture`
did `change.consume()` BEFORE `change.positionChange()`, and `positionChange()` returns
`Offset.Zero` once consumed, so `fp` never accumulated. Fixed with
`positionChangeIgnoreConsumed()` (also immune to the sandbox surface consuming the move). (#10)
- **Hybrid word+char handle-extend** completed (#5, below).
- **Full-screen round-trip corruption FIXED** (was the open bug; see section).
- **Embed WebViews follow the APP theme** — separate from selection, but same surfaces:
see `embed-webview-prefers-color-scheme-limitation` memo / `EmbedWebViewTheme.kt`.
## Why we draw selection ourselves
Editable fields inside an embedded napplet/nsite/browser tab live in the keyless
`:napplet` process and render through `SurfaceControlViewHost` /
`SandboxedSdkView` (privacy-sandbox UI). A WebView rendered into an off-window
surface like this **cannot host the soft keyboard and cannot present Chrome's
own text-selection UI** (handles, the floating action-mode toolbar, the
magnifier). Chrome detects it has nowhere to put that UI and collapses the
selection to the focus endpoint.
So, exactly like Flutter's `TextInputPlugin` did for its virtual-display era, we
relay editing to the main process: an invisible `EditText` (`RemoteImeView`)
hosts the keyboard, `shim.js` mirrors DOM selection/caret geometry out over the
Messenger channel, and `EmbeddedTabLayer` draws the selection UI in Compose on
top of the surface. Everything we want for parity, we draw — the platform gives
us nothing here.
## What native Android gives a text field (the parity target)
This is the full feature inventory we are cloning, with activation/deactivation
rules, so we can check off coverage. Native impl lives in `android.widget.Editor`
(+ `SelectionActionModeHelper`, `android.widget.Magnifier`,
`PopupTouchHandleDrawable` on the Chrome side).
| # | Native feature | Activates | Deactivates | Our status |
|---|----------------|-----------|-------------|------------|
| 1 | **Insertion handle** (the teardrop "blob" under the caret) | tap in editable text; tap again to re-show | typing, scroll start, focus loss, ~4s inactivity timeout | ✅ `InsertionHandle`. **Native availability rule now matched (2026-06-25):** only shown when the field is NON-EMPTY (`Editor` gates the handle behind `text.length() > 0`, via `SelectionUiState.fieldHasText`) — fixes it popping up on focus of an empty box; hides on typing (`onEdited`), scroll (`scrolling`), focus loss, and ~4s inactivity (`hideCaret` timeout), re-showing on the next tap — via an explicit `ime.carettap` shim signal (DOM `click`), so a tap that doesn't move the caret still re-shows it. Device-verified. |
| 2 | **Selection handles** (asymmetric left/right teardrops) | long-press word, double-tap word, drag-extend | tap-collapse, typing, new selection | ✅ `SelectionHandle(isStart)` + drag-to-extend, for BOTH plain page text (`pageExtend`) AND in-field `<input>`/`<textarea>` selections (`fieldExtend`, 2026-06-25). The shim reports the selection's caret feet (`sx/sb`,`ex/eb`, flagged `rng`) via the same mirror-div as the caret; the host holds the range geometry separately so Chrome's transient collapse-to-caret (the re-assert fight) doesn't yank the handles to the field edges. **Tap-to-collapse (2026-06-25):** a single tap inside a selection dismisses it to a caret at the tapped offset + insertion handle — the shim's `click` handler collapses explicitly via `offsetFromPoint` (off-window Chrome doesn't do it itself). Device-verified. |
| 3 | **Floating toolbar** (Cut/Copy/Paste/Select-All/Share/…) | selection made, or tap insertion handle (Paste/Select-All) | scroll/fling (hides, returns on settle), handle drag (hides), tap-collapse | ⚠️ `EmbeddedSelectionToolbar` (Cut/Copy/Paste/Select-All). **Hide-during-handle-drag ✅ device-verified.** Hide-during-scroll via `SelectionUiState.scrolling` (shim `ime.scroll` + re-report on settle). **2026-06-26: the scroll path was hardened** — selection-*reveal* scrolls (forming/re-asserting a range auto-scrolls a `<textarea>`) are no longer treated as user scrolls (they blinked the overlays); the shim guards them via `lastSelActivityAt` and the hide self-heals instead of re-arming. (User content-scroll-hide still wants a clean on-device pass.) Still missing: overflow, Share/Web-Search/process-text. |
| 4 | **Magnifier / loupe** (the zoom bubble above the finger while dragging a handle or the caret) | finger down + moving on a handle or the caret | finger up | ✅ **Built (2026-06-25).** `Magnifier` bubble in [EmbeddedMagnifier.kt] follows the dragged caret/selection handle, showing live magnified page pixels captured in the `:napplet` provider and shipped over IPC ([EmbeddedMagnifierProbe], option B). Both embed paths wired (browser + napplet); verified on device for the browser path. Capture Y is locked to the caret/selection line (X follows the finger). Possible further polish: RGB_565 to cut encode, themed crosshair, clamp capture X to the line so a fast drag past EOL doesn't show blank. |
| 5 | **Word-granularity long-press** then char-extend | long-press | — | ✅ HYBRID word+char in-field handle-extend (2026-06-25, completed): `fieldExtend` keeps per-drag state (`fieldDragWordEnd`/`fieldDragWordStart`, reset on a >250ms gap or edge switch). The drag baselines at the current selection edge; sweeping PAST that word's far boundary snaps to the next whole word (`wordEndAt`/`wordStartAt`), while moving within/back from the furthest-reached word gives CHARACTER precision — so you can fine-tune to a single character (the previously-missing "then char" mode). Page-text extend stays char (no offset model). |
| 6 | **Double-tap = word, long-press = word, (triple-tap/drag = paragraph)** | tap count | — | ✅ double-tap + long-press both select a word (2026-06-25). Chrome word-selects on the 2nd tap, then abandons it by collapsing to the end (off-window quirk); the host re-assert restores it. The shim `click` handler DEFERS its tap-to-collapse ~300ms and the real `dblclick` cancels that timer, so the word selection survives (a timing-only guard was flaky ~40%). Triple-tap/paragraph not done. |
| 7 | **Smart selection / entity expansion** (`TextClassifier`: phone, URL, address, date → entity actions in toolbar) | selection lands on an entity | — | ❌ not built (low priority) |
| 8 | **Drag selected text** (long-press a selection → drag-and-drop to move) | long-press on existing selection | drop | ❌ not built (low priority) |
| 9 | **Auto-scroll while dragging to a viewport edge** | handle dragged near top/bottom edge | finger leaves edge / up | ✅ works (2026-06-25, user-confirmed). The drag driver (`onMagnify`) detects the finger in the surface's top/bottom edge zone and sends `ime.autoscroll`; the shim scrolls the textarea (else the window) and re-reports geometry, flagged so the hide-on-scroll path doesn't fire. Scrolls per drag-move in the edge zone (not on a perfectly-held finger). **Fixed alongside:** the nav drawer's left-edge swipe was hijacking the edge drag — `EmbeddedSelectionDrag.dragging` (set by `onMagnify`) now suspends the drawer's `gesturesEnabled` while a handle is dragged. |
| 10 | **Caret snapping to character boundaries** | always during caret/handle drag | — | ✅ via `offsetFromPoint` binary search + Y-clamp. **2026-06-26 fix:** the insertion-handle drag stopped moving the caret (loupe showed, caret frozen) — the unified `awaitEachGesture` consumed the pointer change BEFORE reading `positionChange()`, which returns `Offset.Zero` once consumed, so the accumulated finger position never advanced. Now reads `positionChangeIgnoreConsumed()` first. |
| 11 | **Themed handle/caret drawables + blink** | always | — | ✅/⚠️ The host-drawn handles use `colorScheme.primary` — which IS the native `textSelectHandle`/accent color — so the handle drawables are themed (the actionable part). The caret bar + selection-highlight are drawn by Chrome inside the off-window surface: the caret already blinks natively, and theming its color/the highlight would mean injecting CSS into arbitrary third-party pages (intrusive; `::selection` was already ruled out as non-painting), so those are intentionally left to Chrome. |
| 12 | **Insertion-handle Paste/Select-All mini-popup** | tap the insertion handle | tap elsewhere | ✅ built (2026-06-25). Tapping the bare insertion handle toggles a Paste/Select-All bar above the caret (`SelectionUiState.insertionPopup`, toggled from the handle's unified tap/drag gesture); tap-elsewhere/typing/blur/selection/scroll dismiss it. Fixed a latent bug: toolbar items now consume the *down* (not just the up) so the tap doesn't bleed through to the surface and blur the field. Device-verified (Select-all selects all text, field stays focused). |
Legend: ✅ done · ⚠️ partial · ❌ missing.
### Activation/deactivation is the hard part
Most of the bugs we already fixed were activation-timing bugs (cursor-jumps-to-end,
collapse-on-tap, focus-transfer races). The remaining features each carry their
own state machine.
**Done (2026-06-25): `SelectionUiState`** (`SelectionUiState.kt`) now centralizes
what used to be scattered flags in `EmbeddedTabLayer` (`showInsertionHandle`,
`showSelectionToolbar`, `fieldGeometry`, `rangeFieldGeometry`, `pageSelection`).
It holds the three mutually-exclusive contexts (insertion caret / in-field range /
page-text range) plus the transient modifiers `dragging` and `scrolling`, and
exposes derived visibility (`insertionHandle`, `fieldHandles`, `fieldToolbar`,
`pageHandles`, `pageToolbar`) so the rules are expressed once:
- **toolbar hides while a handle is dragged** (`dragging`, set from the same
`OnMagnify` lifecycle that drives the loupe) — the dragged handle also hides its
own teardrop (the loupe stands in), like Android; the other handle stays.
- **all overlays hide while scrolling** (`scrolling`, from the shim's `ime.scroll`);
the shim re-reports geometry just before `active=false` so they reappear
repositioned.
Still emergent / TODO: insertion-handle auto-timeout, tap-to-re-show.
**Selection-blink fix (2026-06-25).** A field selection — especially in a `<textarea>`
flickered: off-window Chrome abandons the selection by collapsing the caret to an
endpoint every ~25 ms, and the FIELD re-assert round-tripped through the host EditText
(`RemoteImeView.onPageState``ime.set` → page), leaving a visible collapsed frame each
cycle. Fix: re-assert field selections **synchronously in the shim's `selectionchange`
handler** (mirror of the page-text path that never blinked) — `lastFieldRange`/`lastFieldAt`
tracked via `noteSel()`, and a collapse-to-endpoint within 1500 ms is reverted with `setSel`
(guarded, not re-reported) so it reverts before paint. The host re-assert stays as a
fallback. Device-verified: textarea selection is stable; input select still works.
## Priority order for parity work
1. **Magnifier (#4).** Biggest perceived gap. We already report caret/handle
geometry; the magnifier needs a *magnified pixel view of the surface* at the
drag point. Options:
- **A. Compose-side zoom of a surface snapshot. ❌ RULED OUT (spiked 2026-06-25).**
The surface is a `SurfaceControlViewHost` — we can't trivially `Bitmap`-grab a
remote surface from the main process. We spiked `PixelCopy.request(SurfaceView, …)`
against the live embedded surface (`SurfaceMagnifierProbe`, wired into
`EmbeddedTabLayer` behind `BuildConfig.DEBUG`, fired on field focus). The capture
target is the privacysandbox `ContentView extends SurfaceView` — the only real
child of `SandboxedSdkView` once the session opens. **Result on a clearly-painted
surface (1080×2088): every capture returns `ERROR_SOURCE_NO_DATA`** (center
region, repeated 3×). Cause: the WebView pixels live in a *child* `SurfaceControl`
reparented under the SurfaceView via `ContentView.setChildSurfacePackage(...)`; the
host SurfaceView's *own* buffer is never drawn into, so `PixelCopy` on the parent
reads an empty buffer. Host-side pixel capture of the sandboxed content is not
available. (A `PixelCopy.request(Window, …)` against the host window would also
miss it — the sandbox layer is a *separate* SurfaceControl z-ordered below the
window.)
- **B. Capture in `:napplet` and ship the loupe content. ✅ SPIKED & VIABLE
(2026-06-25).** Inside the keyless provider the WebView IS a real in-window view, so
`WebView.draw(Canvas)` into a software bitmap renders real DOM pixels. Spike added
`MSG_MAGNIFIER_REQUEST`/`MSG_MAGNIFIER_FRAME` to `NappletBrowserContract`:
`NappletBrowserService.onMagnifierRequest` draws a zoomed slice
(`canvas.scale(zoom); translate(-(cx-box/2), -(cy-box/2)); webView.draw(canvas)`),
PNG-encodes it, and ships the bytes back; `EmbeddedBrowserController` (now also an
`EmbeddedMagnifierProbe`) requests on focus and `EmbeddedTabLayer` logs the result.
**10-frame burst, 160px source × 1.5× zoom → 240×240 PNG, center `#FF111111`
(real opaque content):** provider draw 0.71.2 ms steady (≈5 ms cold), provider
total draw+PNG 34 ms steady (≈12 ms cold), client round-trip 48 ms steady
(occasional ~18 ms), payload 815 KB (far under the 1 MB Binder limit). Comfortably
within a frame budget if throttled to ~30 fps.
**✅ Real loupe shipped (2026-06-25).** `MagnifierUiState` + `Magnifier`
(`EmbeddedMagnifier.kt`); the caret/selection handles call an `OnMagnify` callback
on drag start/move/end; `EmbeddedTabLayer` tracks the drag point, throttles capture
requests to one in flight (100 ms timeout), decodes each `MagnifierFrame` to an
`ImageBitmap`, and floats the bubble above the finger (clamped, flips below near the
top). Capture mirrored onto BOTH embed paths (browser:
`NappletBrowserContract`/`NappletBrowserService`/`EmbeddedBrowserController`;
napplet: `NappletEmbedContract`/`NappletHostService`/`EmbeddedNappletController`).
Verified on device (browser): drag → bubble shows live magnified "ello world" with
the caret, centered on the line → follows finger → hides on release. The dead
Option-A probe (`SurfaceMagnifierProbe`) was removed. **Polish done (2026-06-25):**
capture Y is locked to the authoritative caret/selection line (the handles pass a
`lineHalfPx` so the box centers on the line, not the finger or the caret foot); X
still follows the finger. Remaining nice-to-haves: RGB_565/raw to cut PNG encode,
themed crosshair, clamp capture X to the line so a fast drag past EOL isn't blank,
reuse one off-screen bitmap.
- **Gesture-routing caveat (found during the spike).** The host-drawn handles'
drag is fragile: the sandbox `ContentView.onTouchEvent` always returns `true`, so
via Compose's `AndroidView` interop it consumes the drag-move pointer and cancels
the overlay handle's `detectDragGestures` (synthetic `adb` drags on the handle
never produced an `onDrag`). The magnifier trigger should ride the existing
caret/selection-move path (which already round-trips through the shim), not a fresh
Compose drag layered over the surface.
2. **Toolbar state rules (#3 hide-during-drag/scroll) + insertion-handle popup
(#12).** Pure Compose/state work, no platform unknowns. Do alongside the
`SelectionUiState` refactor.
3. **Handle inactivity timeout + tap-to-re-show (#1).** Small.
4. **Double-tap-to-select (#6)** and **word-granularity drag (#5).**
5. **Auto-scroll (#9), themed drawables/blink (#11).**
6. Defer: smart selection (#7), drag-to-move (#8).
## ✅ FIXED — full-screen round-trip corrupted the embedded WebViews (2026-06-26)
**Symptom (was).** Open an embedded field's page in its own full-screen activity,
then `back` to the embedded version. From then on **every** embedded surface in the
`:napplet` process was broken — and it was far more than the selection highlight: DOM
reads returned empty (a field that visibly showed text reported `value == ""`, so typing
prepended at offset 0 and backspace did nothing), DNS died (`ERR_NAME_NOT_RESOLVED`), the
selection highlight stopped painting, and IME broke. The page showed a stale last frame
over a functionally-dead renderer.
**Root cause — two process-global defects in the full-screen hosts** corrupting the
shared multiprocess WebView state the embedded surfaces rely on. (Diagnosed with temporary
logging: page console → logcat, all `onReceivedError`/`onReceivedHttpError`, and the shim's
focused-field type/caret. The user's own insight — "the activity is gone but the service
doesn't come back; am I using something from the activity?" — pointed straight at it.)
1. **`pauseTimers()`/`resumeTimers()` are PROCESS-GLOBAL** (they pause JS, layout and
parsing timers for *every* WebView in the process). `NappletBrowserActivity` /
`NappletHostActivity` `onPause`/`onResume` and `NappletHostService`'s embed
pause/resume all called them on their own lifecycle — so returning from full-screen
*froze* the embedded surfaces, which had no resume of their own. **Fix:** removed ALL
process-global timer calls; rely only on per-WebView `onPause()`/`onResume()` (which
pause just that surface's JS/DOM — still meets the napplet background-security goal).
2. **`WebView.destroy()` while still attached to the window** corrupts the shared
multiprocess renderer (`cr_AwContents: "WebView.destroy() called while WebView is still
attached to window"`). **Fix:** `stopLoading()` + `(parent as ViewGroup).removeView(...)`
before `destroy()` in both full-screen activities' `onDestroy`.
Device-verified: the round-trip no longer corrupts the embeds. This supersedes the old
"recreate the session on return" hypothesis and the ruled-out attempts (`::selection` CSS,
surface resize, focus-cycle, `MSG_WAKE`) — none were the real cause.
## ✅ Embed WebViews follow the app theme (2026-06-26, separate concern)
Not text-selection, but the same off-window surfaces: embedded (and full-screen) WebViews
rendered web content in the *device* theme, ignoring the app's DARK/LIGHT preference.
**Root cause:** WebView's dark decision (`prefers-color-scheme` via algorithmic darkening)
reads the context's **theme** (`?android:attr/isLightTheme`), NOT just `Configuration.uiMode`
— and the off-window `SurfaceControlViewHost` surface context carries neither. The old
`applyNightMode` used `UiModeManager.setNightMode` (permission-gated no-op). **Fix:** build
every WebView from `nightThemedContext()``ContextThemeWrapper(createConfigurationContext(
<night|day>), Theme.DeviceDefault.DayNight)` for the resolved theme — shared in
`nappletHost/.../EmbedWebViewTheme.kt`, used by both embed services + both full-screen
activities. The full debugging arc (config-only context fails; `setForceDark` gone at
targetSdk 37; `setApplicationNightMode` does nothing; it's the unthemed context, not the
process boundary) is in the `embed-webview-prefers-color-scheme-limitation` memo. Upstream
WebView is still buggy here (a cross-process SCVH WebView ignores `uiMode`); the
theme-wrapper is the app-side workaround.
## How to test
The on-device harness lives at **`tools/ime-test/`** (`index.html` + `README.md`).
It's a single page with an `<input>`, a `<textarea>`, and an on-page log that
timestamps focus/selection/input/composition events, **paint latency**,
long-tasks, and main-thread blocks — the instrumentation that pinned the erase,
caret-jump, and first-letter-freeze bugs, and exactly what we'll want when
profiling the magnifier.
Run it (full details in `tools/ime-test/README.md`):
1. `cd tools/ime-test && python3 -m http.server 8765`
2. Reach it: emulator → `http://10.0.2.2:8765`; USB device →
`adb reverse tcp:8765 tcp:8765` then `http://localhost:8765`.
3. Open that URL in the **in-app browser** to load it as an *embedded* tab. (Opening
the same URL full-screen and pressing `back` used to reproduce the
highlight/corruption bug — now fixed; it's still the regression test for it.)
Console log lines are tagged `[ImeDiag]` and surface in `adb logcat` (the
`:napplet` process owns the WebView console). This is a dev tool — nothing under
`tools/` ships, which is why those diagnostic strings are kept out of `src/`.
## Key files
- `commons/src/commonMain/composeResources/files/napplet/shim.js` — DOM bridge.
Geometry sources: `caretCoords` (287), `offsetFromPoint` (318), `fieldGeom`
(336), `reportState` (360), `pageGeom` (462), `sendPageSel` (470), `pageExtend`
(494). A magnifier built via option B would add a loupe-render here.
- `amethyst/.../embed/EmbeddedTabLayer.kt` — the Compose overlay. `InsertionHandle`
(557), `SelectionHandle` (496), `EmbeddedSelectionToolbar` (616),
`PageSelectionOverlay` (460), the `showInsertionHandle`/`showSelectionToolbar`
state to be folded into a `SelectionUiState`. Magnifier popup (option A) lands
here.
- `amethyst/.../embed/RemoteImeView.kt` — invisible host `EditText`; selection
re-assert + copy/cut/paste/select-all + edit callbacks.
- `amethyst/.../embed/EmbeddedImeBridge.kt``SelectionGeometry` (caret + handle
feet + viewport), `ImeEvent.{Focus,State,PageSelection}`, `parseSelectionGeometry`.
- `amethyst/.../{browser/EmbeddedBrowserController,favorites/EmbeddedNappletController}.kt`
— parse `ime.pagesel` + geometry off the Messenger channel.
- Context: `amethyst/plans/2026-06-19-napplet-sandbox-host.md`,
`2026-06-24-napplet-embedded-tabs.md`.
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -90,9 +91,14 @@ fun AccountSwitcherAndLeftDrawerLayout(
dest.hasRoute<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: false
val drawerGesturesEnabled =
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
(
!isTabPagerRoute ||
nav.drawerState.isOpen ||
nav.drawerState.targetValue != nav.drawerState.currentValue
) &&
// Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface,
// so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer.
!EmbeddedSelectionDrag.dragging
ModalNavigationDrawer(
drawerState = nav.drawerState,
@@ -31,6 +31,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import androidx.annotation.RequiresApi
import androidx.compose.runtime.mutableStateListOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
@@ -41,8 +42,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicLong
@@ -61,6 +65,7 @@ class EmbeddedWebAppController(
private val themeType: String = "SYSTEM",
) : EmbeddedSurfaceController,
EmbeddedImeBridge,
EmbeddedMagnifierProbe,
ConsoleBridge {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
@@ -94,6 +99,9 @@ class EmbeddedWebAppController(
override var onImeEvent: ((ImeEvent) -> Unit)? = null
// SPIKE (magnifier #4, option B): provider-side capture round-trip. Removed once the loupe lands.
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
private val connection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -126,6 +134,7 @@ class EmbeddedWebAppController(
pendingAdapter = null
onUrlChanged = null
onImeEvent = null
onMagnifierFrame = null
onLoadStatusChanged = null
consoleLogs.clear()
}
@@ -192,6 +201,19 @@ class EmbeddedWebAppController(
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(ConsoleLogEntry(level, message, source, line))
}
NappletBrowserContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
val bytes = data.getByteArray(NappletBrowserContract.KEY_MAG_BYTES) ?: return true
onMagnifierFrame?.invoke(
MagnifierFrame(
bytes = bytes,
width = data.getInt(NappletBrowserContract.KEY_MAG_W),
height = data.getInt(NappletBrowserContract.KEY_MAG_H),
captureMs = data.getDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS),
requestStampNanos = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T),
),
)
}
else -> return false
}
return true
@@ -245,6 +267,22 @@ class EmbeddedWebAppController(
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
// Stamp the client send time so the reply can be matched / stale frames dropped (same-process clock).
override fun requestMagnifier(
surfaceX: Float,
surfaceY: Float,
boxWidthPx: Int,
boxHeightPx: Int,
zoom: Float,
) = send(NappletBrowserContract.MSG_MAGNIFIER_REQUEST) {
putFloat(NappletBrowserContract.KEY_MAG_X, surfaceX)
putFloat(NappletBrowserContract.KEY_MAG_Y, surfaceY)
putInt(NappletBrowserContract.KEY_MAG_BOX_W, boxWidthPx)
putInt(NappletBrowserContract.KEY_MAG_BOX_H, boxHeightPx)
putFloat(NappletBrowserContract.KEY_MAG_ZOOM, zoom)
putLong(NappletBrowserContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos())
}
private fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
return when (o.optString("type")) {
@@ -256,6 +294,7 @@ class EmbeddedWebAppController(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.blur" -> ImeEvent.Blur
"ime.state" ->
@@ -263,7 +302,16 @@ class EmbeddedWebAppController(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.pagesel" ->
ImeEvent.PageSelection(
active = o.optBoolean("active", false),
text = o.optString("text", ""),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false))
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom")))
else -> null
}
}
@@ -20,6 +20,28 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import org.json.JSONObject
/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */
fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? {
if (o == null) return null
return SelectionGeometry(
left = o.optDouble("l", 0.0).toFloat(),
top = o.optDouble("t", 0.0).toFloat(),
right = o.optDouble("r", 0.0).toFloat(),
bottom = o.optDouble("b", 0.0).toFloat(),
startX = o.optDouble("sx", 0.0).toFloat(),
startBottom = o.optDouble("sb", 0.0).toFloat(),
endX = o.optDouble("ex", 0.0).toFloat(),
endBottom = o.optDouble("eb", 0.0).toFloat(),
viewportWidth = o.optDouble("vw", 0.0).toFloat(),
caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null,
caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null,
caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null,
isRange = o.optBoolean("rng", false),
)
}
/**
* A cross-process editable: the focused field lives in the embedded WebView (a different process/window
* that can't host the soft keyboard), so the main app keeps the keyboard and relays editing across this
@@ -44,6 +66,7 @@ sealed interface ImeEvent {
val text: String,
val selStart: Int,
val selEnd: Int,
val geometry: SelectionGeometry? = null,
) : ImeEvent
/** The field lost focus — dismiss the keyboard. */
@@ -54,5 +77,61 @@ sealed interface ImeEvent {
val text: String,
val selStart: Int,
val selEnd: Int,
val geometry: SelectionGeometry? = null,
) : ImeEvent
/**
* Ordinary page text (not an input) gained or lost a selection. The embedded WebView can't present
* Chrome's copy toolbar/handles in its cross-process surface, so the host draws its own over the page;
* [text] is the selected text to put on the clipboard, [geometry] places the toolbar + drag handles.
*/
data class PageSelection(
val active: Boolean,
val text: String,
val geometry: SelectionGeometry?,
) : ImeEvent
/**
* The embedded page started ([active] true) or finished ([active] false) scrolling. Host-drawn selection
* UI hides while scrolling (its geometry is stale mid-scroll) and reappears, repositioned, on settle
* the shim re-reports geometry just before the `active=false` so the overlays land in the right place.
*/
data class Scroll(
val active: Boolean,
) : ImeEvent
/**
* The user tapped the focused field, placing a bare caret. Re-shows the insertion handle even when the
* tap didn't move the caret (so no [State] fired) native shows the handle on every tap. [geometry]
* carries the caret rect. Gated host-side to non-empty fields, like `Editor`.
*/
data class CaretTap(
val geometry: SelectionGeometry?,
) : ImeEvent
}
/**
* Selection geometry in the page's CSS px (viewport coords). The host scales by surface-width / [viewportWidth]
* to screen px. [left]/[top]/[right]/[bottom] is the bounding box (toolbar anchors above it); ([startX],
* [startBottom]) and ([endX], [endBottom]) are the caret feet where the two drag handles sit.
*/
data class SelectionGeometry(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val startX: Float,
val startBottom: Float,
val endX: Float,
val endBottom: Float,
val viewportWidth: Float,
// Present only when the field's selection is a bare caret (no range): the caret rect, so the host can
// show a draggable insertion handle below it. Null for a range or page-text selection.
val caretX: Float? = null,
val caretTop: Float? = null,
val caretBottom: Float? = null,
// True when this is an in-field *range* selection whose [startX]/[startBottom]/[endX]/[endBottom] are the
// real selection-endpoint feet (not the field-box placeholders). Lets the host hold the range geometry
// through Chrome's transient collapse-to-caret reports during the re-assert fight.
val isRange: Boolean = false,
)
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Hoisted state for the embedded-surface selection loupe (magnifier #4). The bubble shows a magnified,
* live slice of the page captured in the `:napplet` provider (host-side `PixelCopy` can't read the sandbox
* surface see [EmbeddedMagnifierProbe]). While a caret/selection handle is dragged, [anchorPx] tracks the
* drag point in this layer's px (so the bubble follows smoothly even between captures) and [image] is the
* latest decoded frame. Throttle bookkeeping ([lastRequestUptimeMs]/[awaitingFrame]) lives here too but is
* intentionally NOT Compose state it must not trigger recomposition.
*/
class MagnifierUiState {
var visible by mutableStateOf(false)
var anchorPx by mutableStateOf(Offset.Zero)
var image by mutableStateOf<ImageBitmap?>(null)
var lastRequestUptimeMs: Long = 0L
var awaitingFrame: Boolean = false
fun hide() {
visible = false
image = null
awaitingFrame = false
}
}
/**
* The loupe bubble: a rounded, elevated rectangle showing [MagnifierUiState.image], floated above the drag
* point and centered on it horizontally, clamped inside [layerSize]. Like Android's `Magnifier`, it sits a
* little above the finger; if there's no room above (drag near the top), it flips below. Nothing is drawn
* until there's a frame to show.
*/
@Composable
fun Magnifier(
state: MagnifierUiState,
layerSize: IntSize,
bubbleSize: DpSize,
) {
if (!state.visible) return
val img = state.image ?: return
val density = LocalDensity.current
val wPx = with(density) { bubbleSize.width.toPx() }
val hPx = with(density) { bubbleSize.height.toPx() }
val gapPx = with(density) { 28.dp.toPx() }
val left = (state.anchorPx.x - wPx / 2f).coerceIn(0f, (layerSize.width - wPx).coerceAtLeast(0f))
val above = state.anchorPx.y - hPx - gapPx
val top = if (above >= 0f) above else state.anchorPx.y + gapPx
val shape = RoundedCornerShape(12.dp)
Box(
Modifier
.absoluteOffset { IntOffset(left.roundToInt(), top.roundToInt()) }
.size(bubbleSize)
.shadow(8.dp, shape)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape),
) {
// The captured frame is built so its px size ≈ the bubble's px size (source rect = bubble / zoom,
// provider scaled by zoom), so FillBounds maps it 1:1 without distortion.
Image(
bitmap = img,
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize(),
)
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
/**
* Magnifier (#4) capture path see `amethyst/plans/2026-06-25-embed-text-selection-native-parity.md`.
*
* Host-side `PixelCopy` of the sandboxed surface returns `ERROR_SOURCE_NO_DATA` (the WebView pixels live in a
* child SurfaceControl the host never draws into), so the selection loupe's content must be captured INSIDE
* the keyless `:napplet` provider where the WebView is a real in-window view with real pixels and shipped
* back over the Messenger channel. A controller whose surface can produce such captures implements this;
* [EmbeddedTabLayer] discovers it by `as?` cast, mirroring how it treats [EmbeddedImeBridge].
*/
interface EmbeddedMagnifierProbe {
/** A captured loupe frame arrived from the provider. */
var onMagnifierFrame: ((MagnifierFrame) -> Unit)?
/**
* Ask the provider for a zoomed slice of the live page centered on ([surfaceX], [surfaceY]) in surface px
* (which equal the remote WebView's view px the SCVH surface is 1:1 with the SandboxedSdkView).
* [boxWidthPx]×[boxHeightPx] is the source rectangle; the returned image is that, scaled by [zoom].
*/
fun requestMagnifier(
surfaceX: Float,
surfaceY: Float,
boxWidthPx: Int,
boxHeightPx: Int,
zoom: Float,
)
}
/**
* A loupe frame returned by the provider: a PNG ([bytes]) of [width]×[height], plus timing [captureMs] is
* the provider-side draw+encode cost and [requestStampNanos] echoes the client's send stamp so out-of-order
* frames can be dropped.
*/
data class MagnifierFrame(
val bytes: ByteArray,
val width: Int,
val height: Int,
val captureMs: Double,
val requestStampNanos: Long,
) {
override fun equals(other: Any?) = this === other
override fun hashCode() = System.identityHashCode(this)
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Process-level flag: true while the user is dragging a selection/caret handle over an embedded surface.
*
* The left navigation drawer's edge-swipe-to-open competes with a handle drag near the left edge (and with
* the [auto-scroll][EmbeddedTabLayer] edge drag), opening the drawer mid-drag. The drawer reads this to
* suspend its gesture while a handle is held. Kept out of the API-gated [EmbeddedTabHost] so non-R UI (the
* drawer) can read it without a version guard; [EmbeddedTabLayer] is the only writer.
*/
object EmbeddedSelectionDrag {
var dragging by mutableStateOf(false)
}
@@ -20,19 +20,38 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import android.os.SystemClock
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -40,22 +59,59 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import kotlinx.coroutines.delay
import org.json.JSONObject
import kotlin.math.roundToInt
// How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width.
private val OFFSCREEN_SHIFT = 10_000.dp
// Per-drag-move auto-scroll step (CSS px) when a handle is dragged into the surface's top/bottom edge zone.
private const val AUTOSCROLL_STEP_CSS = 40.0
/**
* Drives the selection loupe from a handle drag: [active] shows/hides the bubble, [fingerLayerPx] is the
* drag point in the tab layer's px (where the bubble floats), and ([surfaceX], [surfaceY]) is the same point
* in surface px (what the provider captures around). Called on drag start/move with `true`, on end with `false`.
*/
private typealias OnMagnify = (active: Boolean, fingerLayerPx: Offset, surfaceX: Float, surfaceY: Float) -> Unit
/** Sends a CSS-px positional IME op (caret move / selection extend) to the page; [edge] is null for a caret move. */
private fun EmbeddedImeBridge.sendFieldOp(
type: String,
edge: String?,
cssX: Float,
cssY: Float,
) = sendImeOp(
JSONObject()
.put("type", type)
.apply { if (edge != null) put("edge", edge) }
.put("x", cssX.toDouble())
.put("y", cssY.toDouble())
.toString(),
)
/**
* The persistent surface layer: a full-window overlay (mounted once in the app shell, below the
* navigation drawer and dialogs) that renders **every** warm embedded session's [SandboxedSdkView] and
@@ -121,10 +177,15 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// surface re-render, no black flash between tabs. (Parking at 1dp forced a
// resize + re-render on every switch, which flashed black for ~1s.)
val left = (bounds.left - layerOrigin.x).toDp() + (if (active) 0.dp else OFFSCREEN_SHIFT)
val imeOverlap = if (active) (imeBottomPx - (layerOrigin.y + layerSize.height - bounds.bottom)).coerceAtLeast(0f) else 0f
// Do NOT shrink the surface for the keyboard: resizing reconfigures the cross-process
// SurfaceControlViewHost surface, and the first frame presented after that reconfigure
// stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring
// the focused field above the keyboard via the shim's scrollIntoView on focus.
@Suppress("UNUSED_EXPRESSION")
imeBottomPx
Modifier
.absoluteOffset(left, (bounds.top - layerOrigin.y).toDp())
.size(bounds.width.toDp(), (bounds.height - imeOverlap).coerceAtLeast(1f).toDp())
.size(bounds.width.toDp(), bounds.height.toDp())
}
} else {
// No content bounds reported yet: park tiny off-screen until a tab is shown.
@@ -267,17 +328,45 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
val context = LocalContext.current
val imeBridge = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller as? EmbeddedImeBridge
val imeView = remember { RemoteImeView(context) }
// The embedded WebView can't show Chrome's copy/paste toolbar or selection handles in its cross-process
// surface, so we draw our own over the page and route actions through the hidden EditText (which mirrors
// the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides
// while dragging or scrolling, handles hide while scrolling — are expressed in one place.
val sel = remember { SelectionUiState() }
DisposableEffect(imeBridge) {
imeView.bind(imeBridge)
imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) }
imeView.onEdited = { sel.onEdited() }
imeBridge?.onImeEvent = { event ->
when (event) {
is ImeEvent.Focus -> imeView.onPageFocus(event)
ImeEvent.Blur -> imeView.onPageBlur()
is ImeEvent.State -> imeView.onPageState(event)
is ImeEvent.Focus -> {
imeView.onPageFocus(event)
// A field took focus → the browser dropped any page-text selection; clear its overlay so
// the stale page handles/Copy bar don't sit above (and steal drags from) the field UI.
sel.onPageSelection(null)
// Cancel any in-flight scroll-hide from the page phase: otherwise the field's own
// selection-reveal scrolls keep it armed and the new field handles/toolbar never appear.
sel.scrolling = false
sel.onFieldGeometry(event.geometry, event.text.isNotEmpty())
}
ImeEvent.Blur -> {
imeView.onPageBlur()
sel.onBlur()
}
is ImeEvent.State -> {
imeView.onPageState(event)
sel.onFieldGeometry(event.geometry, event.text.isNotEmpty())
}
is ImeEvent.PageSelection -> sel.onPageSelection(event.takeIf { it.active })
is ImeEvent.Scroll -> sel.scrolling = event.active
is ImeEvent.CaretTap -> sel.onCaretTap(event.geometry)
}
}
onDispose {
imeBridge?.onImeEvent = null
imeView.onRangeSelectionChanged = null
imeView.onEdited = null
sel.reset()
imeView.onPageBlur()
imeView.bind(null)
}
@@ -293,5 +382,492 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
).size(1.dp),
)
}
// Native-style auto-hide: the insertion handle disappears after a few seconds of inactivity; the next
// caret tap re-emits geometry and re-shows it. A drag keeps it alive (dragging gate) so it never
// vanishes mid-drag; the key re-arms whenever the caret moves.
val caretForTimeout = sel.insertionHandle
LaunchedEffect(caretForTimeout, sel.dragging) {
if (caretForTimeout != null && !sel.dragging) {
delay(4_000)
sel.hideCaret()
}
}
// Selection loupe (magnifier #4): while a caret/selection handle is dragged, show a magnified live
// slice of the page. Host-side PixelCopy can't read the sandbox surface (ERROR_SOURCE_NO_DATA), so the
// pixels are captured in the `:napplet` provider and shipped back here (see [EmbeddedMagnifierProbe]).
// The handles report the drag point via [onMagnify]; we track it for the bubble position and ask the
// provider for frames, throttled to one in flight (with a 100 ms timeout) so a fast drag can't flood
// the IPC channel. The bubble follows the finger every move; the bitmap refreshes as frames land.
val magProbe = imeBridge as? EmbeddedMagnifierProbe
val magnifier = remember { MagnifierUiState() }
val magBubble = DpSize(132.dp, 74.dp)
val magZoom = 1.5f
// Source rect (surface px) = bubble px / zoom, so the provider-scaled frame lands ≈ bubble-sized.
val magSrcW = with(density) { (magBubble.width.toPx() / magZoom).roundToInt() }
val magSrcH = with(density) { (magBubble.height.toPx() / magZoom).roundToInt() }
DisposableEffect(magProbe) {
magProbe?.onMagnifierFrame = { frame ->
if (magnifier.visible) {
magnifier.awaitingFrame = false
BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.let { magnifier.image = it.asImageBitmap() }
}
}
onDispose {
magProbe?.onMagnifierFrame = null
magnifier.hide()
}
}
// Auto-scroll (#9): while dragging a handle near the surface's top/bottom edge, nudge the embedded
// content so the selection can keep extending past the viewport — like Android. We scroll on each
// drag-move that's in the edge zone (the finger is usually still micro-moving); the shim keeps the
// overlays up during this programmatic scroll and re-reports geometry so they track.
val edgeZonePx = with(density) { 56.dp.toPx() }
val onMagnify: OnMagnify = { active, fingerPx, surfaceX, surfaceY ->
// Drives the loupe, the toolbar-hide-while-dragging rule (and the dragged handle hides itself
// locally), edge auto-scroll, and suspending the nav drawer's edge swipe — one drag lifecycle.
sel.dragging = active
EmbeddedSelectionDrag.dragging = active
if (active && bounds.height > 0f) {
val oy = bounds.top - layerOrigin.y
val dy =
when {
fingerPx.y < oy + edgeZonePx -> -AUTOSCROLL_STEP_CSS
fingerPx.y > oy + bounds.height - edgeZonePx -> AUTOSCROLL_STEP_CSS
else -> 0.0
}
if (dy != 0.0) imeBridge?.sendImeOp(JSONObject().put("type", "ime.autoscroll").put("dy", dy).toString())
}
if (!active || magProbe == null) {
magnifier.hide()
} else {
magnifier.visible = true
magnifier.anchorPx = fingerPx
val nowMs = SystemClock.uptimeMillis()
if (!magnifier.awaitingFrame || nowMs - magnifier.lastRequestUptimeMs > 100L) {
magnifier.awaitingFrame = true
magnifier.lastRequestUptimeMs = nowMs
magProbe.requestMagnifier(surfaceX, surfaceY, magSrcW, magSrcH, magZoom)
}
}
}
val originX = bounds.left - layerOrigin.x
val originY = bounds.top - layerOrigin.y
val haveBounds = bounds.width > 0f && bounds.height > 0f
// In-field (<input>/<textarea>) range selection: cut/copy/paste/select-all routed to the hidden
// EditText, plus draggable start/end handles (drag → `ime.fieldextend`). The toolbar hides while a
// handle is dragged or the page scrolls; the handles hide only while scrolling.
val fieldItems =
listOf(
"Cut" to {
imeView.cutSelection()
Unit
},
"Copy" to {
imeView.copySelection()
Unit
},
"Paste" to {
imeView.pasteClipboard()
Unit
},
"Select all" to {
imeView.selectAllText()
Unit
},
)
val fieldHandles = sel.fieldHandles
if (haveBounds && fieldHandles != null) {
RangeSelectionOverlay(
geometry = fieldHandles,
surfaceOriginX = originX,
surfaceOriginY = originY,
scale = bounds.width / fieldHandles.viewportWidth,
showToolbar = sel.fieldToolbar != null,
toolbarItems = fieldItems,
onMagnify = onMagnify,
onExtend = { edge, cssX, cssY -> imeBridge?.sendFieldOp("ime.fieldextend", edge, cssX, cssY) },
)
} else if (haveBounds && sel.fieldToolbar != null) {
// Authoritative range but no geometry yet → a centered fallback toolbar (no handles).
EmbeddedSelectionToolbar(items = fieldItems, centerXpx = originX + bounds.width / 2f, topYpx = originY + with(density) { 8.dp.toPx() })
}
// Bare caret in a field (no range): a draggable insertion handle under the cursor, like Android's.
val caretGeom = sel.insertionHandle
if (haveBounds && caretGeom?.caretX != null && caretGeom.caretBottom != null) {
val scale = bounds.width / caretGeom.viewportWidth
// Half the caret's line height (layer px), so the loupe capture centers on the text line.
val caretLineHalf =
(((caretGeom.caretTop?.let { caretGeom.caretBottom - it }) ?: 0f) * scale / 2f)
.coerceIn(0f, with(density) { 28.dp.toPx() })
InsertionHandle(
tipPx = Offset(originX + caretGeom.caretX * scale, originY + caretGeom.caretBottom * scale),
surfaceOriginX = originX,
surfaceOriginY = originY,
scale = scale,
lineHalfPx = caretLineHalf,
onMagnify = onMagnify,
onTap = { sel.toggleInsertionPopup() },
onDragTo = { cssX, cssY -> imeBridge?.sendFieldOp("ime.caretmove", null, cssX, cssY) },
)
}
// Tapping the bare insertion handle opens a small Paste / Select-all popup above the caret (native).
val popupCaret = sel.insertionPopupAt
if (haveBounds && popupCaret?.caretX != null && popupCaret.caretTop != null) {
val scale = bounds.width / popupCaret.viewportWidth
val gap = with(density) { 8.dp.toPx() }
val toolbarH = with(density) { 44.dp.toPx() }
val caretTopPx = originY + popupCaret.caretTop * scale
val caretBottomPx = originY + (popupCaret.caretBottom ?: popupCaret.caretTop) * scale
val topY = if (caretTopPx - toolbarH - gap > originY) caretTopPx - toolbarH - gap else caretBottomPx + gap
EmbeddedSelectionToolbar(
items =
listOf(
"Paste" to {
imeView.pasteClipboard()
sel.hideInsertionPopup()
},
"Select all" to {
imeView.selectAllText()
sel.hideInsertionPopup()
},
),
centerXpx = originX + popupCaret.caretX * scale,
topYpx = topY,
)
}
// Plain page-text selection: a Copy bar over the selection + a drag handle at each end (same rules).
val pageHandles = sel.pageHandles
val pageSel = sel.pageSelection
if (haveBounds && pageHandles != null && pageSel != null) {
RangeSelectionOverlay(
geometry = pageHandles,
surfaceOriginX = originX,
surfaceOriginY = originY,
scale = bounds.width / pageHandles.viewportWidth,
showToolbar = sel.pageToolbar != null,
toolbarItems =
listOf(
"Copy" to {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("selection", pageSel.text))
Unit
},
),
onMagnify = onMagnify,
onExtend = { edge, cssX, cssY -> imeBridge?.sendFieldOp("ime.pageextend", edge, cssX, cssY) },
)
}
// The loupe sits above everything else, following the active handle/caret drag.
Magnifier(magnifier, layerSize, magBubble)
}
}
/**
* Host-drawn selection overlay shared by in-field (`<input>`/`<textarea>`) and plain page-text selections:
* a floating toolbar above the selection (only when [showToolbar] it's hidden mid-drag) plus a draggable
* handle at each end. [geometry] is in the page's CSS px; [scale] and the surface origin map those to this
* layer's px. Dragging a handle reports the new CSS-px target via [onExtend], which the shim turns into a
* selection extension; the resulting geometry update repositions everything.
*/
@Composable
private fun RangeSelectionOverlay(
geometry: SelectionGeometry,
surfaceOriginX: Float,
surfaceOriginY: Float,
scale: Float,
showToolbar: Boolean,
toolbarItems: List<Pair<String, () -> Unit>>,
onMagnify: OnMagnify?,
onExtend: (edge: String, cssX: Float, cssY: Float) -> Unit,
) {
val density = LocalDensity.current
fun mapX(css: Float) = surfaceOriginX + css * scale
fun mapY(css: Float) = surfaceOriginY + css * scale
if (showToolbar) {
val toolbarH = with(density) { 44.dp.toPx() }
val gap = with(density) { 8.dp.toPx() }
val topPx = mapY(geometry.top)
val toolbarY = if (topPx - toolbarH - gap > surfaceOriginY) topPx - toolbarH - gap else mapY(geometry.bottom) + gap
EmbeddedSelectionToolbar(
items = toolbarItems,
centerXpx = mapX((geometry.left + geometry.right) / 2f),
topYpx = toolbarY,
)
}
// Half the selection's line height (layer px), clamped — the bounding box spans every selected line, so
// cap it at a sane single-line height so the loupe centers on the dragged endpoint's line, not the middle
// of a tall multi-line box.
val lineHalfPx = ((geometry.bottom - geometry.top) * scale / 2f).coerceAtMost(with(density) { 24.dp.toPx() })
SelectionHandle(Offset(mapX(geometry.startX), mapY(geometry.startBottom)), isStart = true, surfaceOriginX, surfaceOriginY, scale, lineHalfPx, onMagnify) { x, y -> onExtend("start", x, y) }
SelectionHandle(Offset(mapX(geometry.endX), mapY(geometry.endBottom)), isStart = false, surfaceOriginX, surfaceOriginY, scale, lineHalfPx, onMagnify) { x, y -> onExtend("end", x, y) }
}
/**
* A draggable selection-endpoint handle drawn as Android's teardrop: a disc with one squared corner that
* points to the caret. The start handle points up-right (hangs to the left of the selection start); the end
* handle points up-left. [tipPx] is the caret foot (this layer's px) where the point sits; a drag reports
* the new tip position in the page's CSS px.
*/
@Composable
private fun SelectionHandle(
tipPx: Offset,
isStart: Boolean,
surfaceOriginX: Float,
surfaceOriginY: Float,
scale: Float,
lineHalfPx: Float,
onMagnify: OnMagnify?,
onDragTo: (cssX: Float, cssY: Float) -> Unit,
) {
val sizeDp = 22.dp
val sizePx = with(LocalDensity.current) { sizeDp.toPx() }
val color = MaterialTheme.colorScheme.primary
val currentTip by rememberUpdatedState(tipPx)
val currentMagnify by rememberUpdatedState(onMagnify)
var dragTip by remember { mutableStateOf<Offset?>(null) }
val tip = dragTip ?: tipPx
// Loupe capture: X follows the finger, Y is locked to the authoritative endpoint's line ([currentTip] is
// the foot, so lift by half the line height) — so the bubble shows the line being edited, not wherever the
// finger drifts vertically.
fun magnify(
fingerPx: Offset,
active: Boolean = true,
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
// Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner.
val boxLeft = if (isStart) tip.x - sizePx else tip.x
Box(
Modifier
.absoluteOffset { IntOffset(boxLeft.roundToInt(), tip.y.roundToInt()) }
.size(sizeDp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
dragTip = currentTip
magnify(currentTip)
},
onDrag = { change, delta ->
change.consume()
val np = (dragTip ?: currentTip) + delta
dragTip = np
onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale)
magnify(np)
},
onDragEnd = {
dragTip = null
magnify(Offset.Zero, active = false)
},
onDragCancel = {
dragTip = null
magnify(Offset.Zero, active = false)
},
)
},
) {
// Hide the teardrop while THIS handle is being dragged — the loupe stands in for it, like Android.
if (dragTip == null) {
Canvas(Modifier.fillMaxSize()) {
val d = size.minDimension
val path =
Path().apply {
if (isStart) {
moveTo(d, 0f)
lineTo(d, d / 2f)
arcTo(Rect(0f, 0f, d, d), 0f, 270f, false)
close()
} else {
moveTo(0f, 0f)
lineTo(0f, d / 2f)
arcTo(Rect(0f, 0f, d, d), 180f, -270f, false)
close()
}
}
drawPath(path, color)
}
}
}
}
/**
* A draggable insertion (cursor) handle drawn as Android's upward teardrop, hanging below a bare caret with
* its tip on the caret. [tipPx] is the caret foot (this layer's px); a drag reports the new caret position in
* the page's CSS px, which the shim maps back to a character offset.
*/
@Composable
private fun InsertionHandle(
tipPx: Offset,
surfaceOriginX: Float,
surfaceOriginY: Float,
scale: Float,
lineHalfPx: Float,
onMagnify: OnMagnify?,
onTap: () -> Unit,
onDragTo: (cssX: Float, cssY: Float) -> Unit,
) {
val density = LocalDensity.current
val wPx = with(density) { 20.dp.toPx() }
val hPx = wPx * 1.4f
val color = MaterialTheme.colorScheme.primary
val currentTip by rememberUpdatedState(tipPx)
val currentMagnify by rememberUpdatedState(onMagnify)
val currentTap by rememberUpdatedState(onTap)
// Loupe capture: X follows the finger, Y is locked to the authoritative caret's line ([currentTip] is the
// caret foot, so lift by half the line height) — so the bubble shows the edited line, not wherever the
// finger drifts vertically (e.g. a diagonal drag in a textarea).
fun magnify(
fingerPx: Offset,
active: Boolean = true,
) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx)
// Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx,
// which snaps to a character position as the move round-trips through the shim), while the finger drives
// the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off.
var fingerPx by remember { mutableStateOf<Offset?>(null) }
Box(
Modifier
.absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) }
.size(with(density) { wPx.toDp() }, with(density) { hPx.toDp() })
// One unified gesture so a TAP (toggle the Paste/Select-All popup) and a DRAG (move the caret)
// don't fight each other. We consume the down so the tap never bleeds to the surface (which would
// place a caret there and dismiss the popup we're opening).
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume()
var dragging = false
var fp = currentTip
while (true) {
val change = awaitPointerEvent().changes.firstOrNull() ?: break
if (!change.pressed) {
if (!dragging) {
currentTap()
} else {
fingerPx = null
magnify(Offset.Zero, active = false)
}
break
}
if (!dragging && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) {
dragging = true
fp = currentTip
magnify(currentTip)
}
if (dragging) {
// Read the delta BEFORE consuming: positionChange() returns Offset.Zero once the
// change isConsumed, so consuming first (or the sandbox surface consuming the move in
// an earlier pass) would freeze `fp` at the caret — the handle drags but the caret
// never moves. positionChangeIgnoreConsumed() is immune to both.
fp += change.positionChangeIgnoreConsumed()
change.consume()
fingerPx = fp
onDragTo((fp.x - surfaceOriginX) / scale, (fp.y - surfaceOriginY) / scale)
magnify(fp)
}
}
}
},
) {
// Hide the teardrop while dragging — the loupe stands in for it, like Android.
if (fingerPx == null) {
Canvas(Modifier.fillMaxSize()) {
val r = size.width / 2f
val cx = size.width / 2f
val cy = size.height - r
val path =
Path().apply {
moveTo(cx, 0f)
lineTo(cx + r, cy)
arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 0f, 180f, false)
lineTo(cx, 0f)
close()
}
drawPath(path, color)
}
}
}
}
/**
* Host-drawn copy/paste bar for an embedded page selection. Chrome can't present its own action-mode in the
* cross-process surface, so this floats over the active tab; each action runs on the hidden [RemoteImeView]
* (which mirrors the page's text + selection), so cut/paste relay back to the page through the normal path.
* Uses pointer-input rather than `clickable` so tapping it doesn't pull focus off the EditText (which would
* drop the keyboard and the selection).
*/
@Composable
private fun EmbeddedSelectionToolbar(
items: List<Pair<String, () -> Unit>>,
centerXpx: Float,
topYpx: Float,
) {
// Center in a SINGLE layout pass: measure the bar, then place it at centerX width/2. (The old
// measure-then-offset approach rendered it left-edge-at-centerX for one frame, so it visibly slid in
// from the right each time the bar was (re)composed.)
Layout(
content = {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
tonalElevation = 3.dp,
shadowElevation = 6.dp,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
items.forEachIndexed { i, (label, action) ->
if (i > 0) {
Box(
Modifier
.width(1.dp)
.height(20.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
SelectionToolbarItem(label, action)
}
}
}
},
) { measurables, constraints ->
val bar = measurables.first().measure(constraints.copy(minWidth = 0, minHeight = 0))
layout(constraints.maxWidth, constraints.maxHeight) {
bar.place((centerXpx - bar.width / 2f).roundToInt(), topYpx.roundToInt())
}
}
}
@Composable
private fun SelectionToolbarItem(
label: String,
onClick: () -> Unit,
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
// Consume the DOWN (not just the up) so the tap never bleeds through to the embedded surface
// beneath — otherwise tapping the bar over non-editable page area blurs the field.
.pointerInput(label) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false).consume()
val up = waitForUpOrCancellation()
if (up != null) {
up.consume()
onClick()
}
}
}.padding(horizontal = 12.dp, vertical = 10.dp),
)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.annotation.SuppressLint
import android.content.Context
import android.os.SystemClock
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
@@ -93,7 +94,10 @@ class RemoteImeView(
count: Int,
) {}
override fun afterTextChanged(s: Editable?) = schedule()
override fun afterTextChanged(s: Editable?) {
if (!applyingRemote) onEdited?.invoke()
schedule()
}
},
)
// The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline).
@@ -108,26 +112,101 @@ class RemoteImeView(
this.bridge = bridge
}
/**
* Notified when the mirrored selection becomes (true) or stops being (false) a non-empty range. The
* embedded WebView can't present Chrome's copy/paste toolbar in its cross-process surface, so the host
* shows its own over the page and routes the actions back through [copy]/[cut]/[paste]/[selectAll],
* which run on this EditText's Editable (and so relay to the page through the normal edit path).
*/
var onRangeSelectionChanged: ((Boolean) -> Unit)? = null
private var hadRange = false
// Off-window Chrome abandons a selection by momentarily collapsing the caret to an endpoint, which we (or
// the page shim) re-assert right back — so the mirrored selection flickers range→caret→range within a few
// ms during a long-press/double-tap. The host-drawn handles/toolbar are gated on [hadRange], so reporting
// every flip blinks them off and back on, in lock-step with each collapse cycle. Native never shows that
// churn. So we DEFER the "range lost" signal by [RANGE_LOSS_DEBOUNCE_MS]: a re-assert that restores the
// range first cancels the pending hide, and only a selection that truly STAYS collapsed hides the overlays.
// Gaining a range is always reported immediately.
private val reportRangeLost =
Runnable {
if (selectionStart == selectionEnd && hadRange) {
hadRange = false
onRangeSelectionChanged?.invoke(false)
}
}
/** Fired when the user edits text via the keyboard (not on programmatic page-state applies). The host
* hides the insertion handle while typing, the way Android does. */
var onEdited: (() -> Unit)? = null
fun copySelection(): Boolean = onTextContextMenuItem(android.R.id.copy)
fun cutSelection(): Boolean = onTextContextMenuItem(android.R.id.cut)
fun pasteClipboard(): Boolean = onTextContextMenuItem(android.R.id.paste)
fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll)
/** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */
fun onPageFocus(focus: ImeEvent.Focus) {
configureFor(focus)
applyRemote(focus.text, focus.selStart, focus.selEnd)
// Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it
// gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the
// page — so a tap mid-text lands the caret at the end of the field. Seeding AFTER focus makes the
// tap position the final state (the focus-induced end-position only schedules a flush that then
// coalesces to this seed, a no-op).
requestFocus()
imm.restartInput(this)
applyRemote(focus.text, focus.selStart, focus.selEnd)
// Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise).
post {
if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
// When the current selection first became a range, and how many of its collapse-abandonments we've
// re-asserted. Chrome abandons a selection *immediately* (~60ms); a deliberate user tap-to-collapse comes
// later — so we only re-assert within a short window of the range forming, bounded for safety.
private var rangeBecameAt = 0L
private var reassertCount = 0
/** The page changed the field itself (its JS, autofill): mirror it without echoing back. */
fun onPageState(state: ImeEvent.State) {
if (text?.toString() == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return
val curText = text?.toString() ?: ""
// Chrome can't present selection handles in the embedded surface, so it abandons a selection by
// collapsing the caret to one of the range's endpoints, right after it forms. We hold the
// authoritative selection here, so a collapse to an endpoint of our current range (same text) within
// the window is that abandonment, not a user action: re-assert our range instead of accepting it.
val collapsedToEndpoint =
selectionStart != selectionEnd &&
state.selStart == state.selEnd &&
state.text == curText &&
(state.selStart == selectionStart || state.selStart == selectionEnd)
if (collapsedToEndpoint && reassertCount < MAX_REASSERT &&
SystemClock.uptimeMillis() - rangeBecameAt < REASSERT_WINDOW_MS
) {
reassertCount++
// Keep the window alive across the fight: Chrome re-abandons the selection every ~600ms while the
// gesture is held, so each re-assert restarts the clock — bounded by reassertCount so a page that
// truly keeps the caret collapsed still wins.
rangeBecameAt = SystemClock.uptimeMillis()
lastSent = null // force a non-no-op flush so the range re-ships
flushState()
return
}
reassertCount = 0
if (curText == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return
applyRemote(state.text, state.selStart, state.selEnd)
}
/** The page field blurred: drop the keyboard. */
fun onPageBlur() {
removeCallbacks(reportRangeLost)
if (hadRange) {
hadRange = false
onRangeSelectionChanged?.invoke(false)
}
clearFocus()
imm.hideSoftInputFromWindow(windowToken, 0)
}
@@ -138,11 +217,18 @@ class RemoteImeView(
selEnd: Int,
) {
applyingRemote = true
setText(newText)
// Only replace the buffer when the text actually changed. A page that rewrites its field's
// selection on a timer (without changing the text) would otherwise force a full setText on every
// update — clearing the composing region and restarting the IME — which needlessly churns the
// keyboard and contends with the user's own typing. Reposition the cursor without touching the buffer.
if (text?.toString() != newText) setText(newText)
val len = text?.length ?: 0
setSelection(selStart.coerceIn(0, len), selEnd.coerceIn(0, len))
applyingRemote = false
lastSent = stateJson().toString()
// Start the abandonment window when a fresh range appears, so onPageState can tell Chrome's instant
// collapse from a later user tap-to-collapse.
if (selectionStart != selectionEnd) rangeBecameAt = SystemClock.uptimeMillis()
}
private fun schedule() {
@@ -156,6 +242,20 @@ class RemoteImeView(
selEnd: Int,
) {
super.onSelectionChanged(selStart, selEnd)
val isRange = selStart != selEnd
if (isRange) {
// A range is back (or still here): cancel any pending hide and show immediately.
removeCallbacks(reportRangeLost)
if (!hadRange) {
hadRange = true
onRangeSelectionChanged?.invoke(true)
}
} else if (hadRange) {
// Collapsed — but this may be Chrome's transient abandonment we're about to re-assert. Defer the
// hide; if the range returns within the window, the show branch above cancels this.
removeCallbacks(reportRangeLost)
postDelayed(reportRangeLost, RANGE_LOSS_DEBOUNCE_MS)
}
schedule()
}
@@ -237,4 +337,19 @@ class RemoteImeView(
return result
}
}
private companion object {
// Chrome re-abandons a held selection repeatedly; cover a long-press hold (each re-assert restarts the
// window) while still giving up if the page truly keeps re-collapsing forever.
private const val MAX_REASSERT = 12
// Wider than Chrome's ~600ms re-collapse interval so consecutive abandonments stay inside the window,
// yet short enough that a deliberate user tap-to-collapse (well after the gesture) is accepted.
private const val REASSERT_WINDOW_MS = 800L
// How long a collapse must persist before we hide the host-drawn selection overlays. The shim/host
// re-assert restores an abandonment collapse within a frame or two (one IPC hop), so this only needs to
// outlast that round-trip — comfortably short enough that a genuine tap-to-collapse still feels instant.
private const val RANGE_LOSS_DEBOUNCE_MS = 250L
}
}
@@ -0,0 +1,183 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Single source of truth for the host-drawn selection UI over an embedded surface. Folds together what used
* to be scattered booleans/geometries (`showSelectionToolbar`, `showInsertionHandle`, `fieldGeometry`,
* `rangeFieldGeometry`, `pageSelection`) so the show/hide rules are expressed in one place.
*
* Three mutually-exclusive selection contexts can be active: a bare caret in a field ([insertionGeometry]),
* a range inside an `<input>`/`<textarea>` ([fieldRange], gated by [fieldHasRange] the authoritative
* "is there a selection" signal from the mirror [RemoteImeView]), or a plain page-text selection
* ([pageSelection]). [dragging] and [scrolling] are the transient modifiers:
*
* - **dragging** a handle/caret hides the floating toolbar (and the dragged handle hides itself while the
* loupe stands in), matching Android; the other handle stays so you can see the live range.
* - **scrolling** hides every overlay (its geometry is stale mid-scroll); the shim re-reports geometry on
* settle, then clears this, so they reappear in the right place.
*/
@Stable
class SelectionUiState {
/** Latest field geometry; carries the caret rect when collapsed, positioning the insertion handle. */
var fieldGeometry by mutableStateOf<SelectionGeometry?>(null)
private set
/** Held in-field range geometry (real endpoint feet) — survives Chrome's transient collapse-to-caret. */
var fieldRange by mutableStateOf<SelectionGeometry?>(null)
private set
/** Authoritative "the field selection is a non-empty range", from the mirror EditText. */
var fieldHasRange by mutableStateOf(false)
private set
/** A bare caret handle is showing — set by a tap-placed caret in non-empty text, cleared on typing/blur/timeout. */
var caretShown by mutableStateOf(false)
private set
/**
* Whether the focused field currently has any text. Native (`Editor.onTouchUpEvent`) only offers the
* insertion handle when `text.length() > 0`, so an empty field never gets a draggable cursor handle
* (just the blinking caret). Gating on this is what stops the handle popping up on focus of an empty box.
*/
var fieldHasText by mutableStateOf(false)
private set
/** Plain page-text (non-editable) selection. */
var pageSelection by mutableStateOf<ImeEvent.PageSelection?>(null)
private set
/** A handle/caret is being dragged. */
var dragging by mutableStateOf(false)
/** The embedded surface is scrolling. */
var scrolling by mutableStateOf(false)
/** Tapping the bare insertion handle opens a small Paste/Select-All popup (native); tap-elsewhere closes it. */
var insertionPopup by mutableStateOf(false)
private set
private val insertionGeometry: SelectionGeometry?
get() = fieldGeometry?.takeIf { it.caretX != null && it.caretBottom != null && it.viewportWidth > 0f }
// ---- derived visibility (read in composition; track the backing state) ----
val insertionHandle: SelectionGeometry?
get() = if (caretShown && fieldHasText && !fieldHasRange && !scrolling) insertionGeometry else null
/** Geometry for the insertion-handle Paste/Select-All popup, or null when it shouldn't show. */
val insertionPopupAt: SelectionGeometry?
get() = if (insertionPopup && !dragging) insertionHandle else null
val fieldHandles: SelectionGeometry?
get() = if (fieldHasRange && !scrolling) fieldRange?.takeIf { it.viewportWidth > 0f } else null
val fieldToolbar: SelectionGeometry?
get() = if (fieldHasRange && !dragging && !scrolling) (fieldRange ?: fieldGeometry) else null
private val pageGeom: SelectionGeometry?
get() = pageSelection?.takeIf { it.active }?.geometry?.takeIf { it.viewportWidth > 0f }
val pageHandles: SelectionGeometry?
get() = if (!scrolling) pageGeom else null
val pageToolbar: SelectionGeometry?
get() = if (!dragging && !scrolling) pageGeom else null
// ---- signal sinks ----
fun onFieldGeometry(
g: SelectionGeometry?,
hasText: Boolean,
) {
fieldHasText = hasText
if (g == null) return
fieldGeometry = g
if (g.isRange) fieldRange = g
// Only a tap-placed caret in NON-empty text gets the handle — like native. (Typing suppresses the
// echo, so this fires for taps, not keystrokes; see RemoteImeView / shim __nappletIme guard.)
if (g.caretX != null && hasText) caretShown = true
}
/** From [RemoteImeView.onRangeSelectionChanged]: the field gained/lost a non-empty selection. */
fun onFieldRangeToggle(hasRange: Boolean) {
fieldHasRange = hasRange
if (!hasRange) fieldRange = null
if (hasRange) insertionPopup = false // a selection formed → no insertion popup
}
/**
* The user tapped the focused field (placing a caret) (re-)show the insertion handle even if the caret
* didn't move. Gated to non-empty text by the [insertionHandle] getter, exactly like native.
*/
fun onCaretTap(g: SelectionGeometry?) {
if (g != null) fieldGeometry = g
caretShown = true
insertionPopup = false // a tap in the text dismisses the popup (and moves the caret)
}
/** The user typed: hide the insertion handle, like Android. */
fun onEdited() {
caretShown = false
insertionPopup = false
}
/** Native auto-hides the insertion handle after ~a few seconds of inactivity; a later tap re-shows it. */
fun hideCaret() {
caretShown = false
insertionPopup = false
}
/** Tap on the bare insertion handle toggles its Paste/Select-All popup. */
fun toggleInsertionPopup() {
insertionPopup = !insertionPopup
}
fun hideInsertionPopup() {
insertionPopup = false
}
fun onBlur() {
fieldGeometry = null
fieldRange = null
fieldHasRange = false
fieldHasText = false
caretShown = false
insertionPopup = false
}
fun onPageSelection(sel: ImeEvent.PageSelection?) {
pageSelection = sel
}
/** Full teardown when the active tab/controller changes. */
fun reset() {
onBlur()
pageSelection = null
dragging = false
scrolling = false
}
}
@@ -31,6 +31,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
@@ -39,8 +40,11 @@ import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicLong
@@ -59,7 +63,8 @@ class EmbeddedNostrAppController(
private val appContext: Context,
private val params: Bundle,
) : EmbeddedSurfaceController,
EmbeddedImeBridge {
EmbeddedImeBridge,
EmbeddedMagnifierProbe {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -93,6 +98,8 @@ class EmbeddedNostrAppController(
override var onImeEvent: ((ImeEvent) -> Unit)? = null
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
private val connection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -125,6 +132,7 @@ class EmbeddedNostrAppController(
onStateChanged = null
onNotice = null
onImeEvent = null
onMagnifierFrame = null
onLoadStatusChanged = null
}
@@ -183,6 +191,19 @@ class EmbeddedNostrAppController(
val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false
onLoadState(isLoading, failed)
}
NappletEmbedContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true
onMagnifierFrame?.invoke(
MagnifierFrame(
bytes = bytes,
width = data.getInt(NappletEmbedContract.KEY_MAG_W),
height = data.getInt(NappletEmbedContract.KEY_MAG_H),
captureMs = data.getDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS),
requestStampNanos = data.getLong(NappletEmbedContract.KEY_MAG_REQ_T),
),
)
}
else -> return false
}
return true
@@ -190,6 +211,21 @@ class EmbeddedNostrAppController(
override fun sendImeOp(json: String) = send(NappletEmbedContract.MSG_IME_OP) { putString(NappletEmbedContract.KEY_IME_PAYLOAD, json) }
override fun requestMagnifier(
surfaceX: Float,
surfaceY: Float,
boxWidthPx: Int,
boxHeightPx: Int,
zoom: Float,
) = send(NappletEmbedContract.MSG_MAGNIFIER_REQUEST) {
putFloat(NappletEmbedContract.KEY_MAG_X, surfaceX)
putFloat(NappletEmbedContract.KEY_MAG_Y, surfaceY)
putInt(NappletEmbedContract.KEY_MAG_BOX_W, boxWidthPx)
putInt(NappletEmbedContract.KEY_MAG_BOX_H, boxHeightPx)
putFloat(NappletEmbedContract.KEY_MAG_ZOOM, zoom)
putLong(NappletEmbedContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos())
}
private fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
return when (o.optString("type")) {
@@ -201,6 +237,7 @@ class EmbeddedNostrAppController(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.blur" -> ImeEvent.Blur
"ime.state" ->
@@ -208,7 +245,16 @@ class EmbeddedNostrAppController(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.pagesel" ->
ImeEvent.PageSelection(
active = o.optBoolean("active", false),
text = o.optString("text", ""),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false))
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom")))
else -> null
}
}
@@ -191,6 +191,7 @@
if (IME_PROXY) (function(){
var el = null; // the focused editable element, or null
var inComposition = false;
function perfNow(){ try { return performance.now(); } catch (_) { return 0; } }
function isEditable(n){
if (!n) return false;
@@ -205,7 +206,22 @@
}
function isCE(n){ return !!(n && n.isContentEditable); }
function valOf(n){ return isCE(n) ? n.textContent : (n.value || ''); }
function setVal(n, v){ if (isCE(n)) n.textContent = v; else n.value = v; }
// Controlled-input frameworks (React, Preact, …) install an INSTANCE-level `value` setter on the
// <input>/<textarea> that records the last value they wrote, and then suppress their onChange whenever
// the element's value already equals that recorded value. A plain `n.value = v` assignment goes through
// that tracker, so our programmatic edit looks like a no-op to the framework: onChange never fires, its
// state stays stale, and on the next render it reconciles the field straight back to the stale value —
// wiping what we just typed. Writing through the NATIVE prototype setter sets the real value without
// touching the tracker, so the framework's input handler sees value != tracked, detects the change, and
// commits it. Identical to `n.value = v` for plain (non-framework) pages.
var nativeInputValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; } catch (_) { return null; } })();
var nativeAreaValueSet = (function(){ try { return Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; } catch (_) { return null; } })();
function setVal(n, v){
if (isCE(n)) { n.textContent = v; return; }
var set = (typeof HTMLTextAreaElement !== 'undefined' && n instanceof HTMLTextAreaElement) ? nativeAreaValueSet : nativeInputValueSet;
if (set) { try { set.call(n, v); return; } catch (_) {} }
n.value = v;
}
// --- contenteditable selection/replacement, mapped through char offsets into textContent ---
// We can't use setSelectionRange/value on a contenteditable root; instead we map a char offset
@@ -261,31 +277,144 @@
return [n.selectionStart || 0, n.selectionEnd || 0];
}
function setSel(n, s, e){
// No-op if already there: re-applying the same selection still fires `select`/`selectionchange`, and
// every such redundant event ripples into a host report → geometry update → toolbar/handle recompose
// (visible churn). Our re-asserts/re-applies frequently target the current range, so guard them here.
var cur = selOf(n);
if (cur[0] === s && cur[1] === e) return;
lastSelActivityAt = perfNow(); // a real selection write → the field may auto-scroll to reveal it
if (isCE(n)) ceSetSel(n, s, e);
else { try { n.setSelectionRange(s, e); } catch (_) {} }
}
// The DOM exposes no caret/selection rect for a position inside an <input>/<textarea>, so we mirror the
// field into a hidden div (same font/padding/wrapping) and measure where a marker span lands — the
// well-known "textarea-caret-position" technique. Used to place the insertion handle (and drag it).
var CARET_PROPS = ['direction','boxSizing','width','height','overflowX','overflowY','borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth','paddingTop','paddingRight','paddingBottom','paddingLeft','fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust','lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration','letterSpacing','wordSpacing','tabSize','MozTabSize'];
function caretCoords(n, position){
try {
var isInput = (n.nodeName || '').toUpperCase() === 'INPUT';
var computed = window.getComputedStyle(n);
var div = document.createElement('div');
var s = div.style;
s.position = 'absolute'; s.visibility = 'hidden'; s.whiteSpace = isInput ? 'nowrap' : 'pre-wrap'; s.wordWrap = 'break-word'; s.overflow = 'hidden';
for (var i = 0; i < CARET_PROPS.length; i++) { s[CARET_PROPS[i]] = computed[CARET_PROPS[i]]; }
var val = valOf(n);
div.textContent = val.substring(0, position);
if (isInput) div.textContent = div.textContent.replace(/\s/g, ' ');
var span = document.createElement('span');
span.textContent = val.substring(position) || '.';
div.appendChild(span);
document.body.appendChild(div);
var caretL = span.offsetLeft, caretT = span.offsetTop;
var lineHeight = parseInt(computed.lineHeight) || parseInt(computed.fontSize) || 16;
document.body.removeChild(div);
// offsetLeft/Top are measured from the mirror's *inner* (padding) edge, but getBoundingClientRect is the
// *outer* border box — so add the field's border widths to land on the real caret.
var bl = parseFloat(computed.borderLeftWidth) || 0, bt = parseFloat(computed.borderTopWidth) || 0;
var rect = n.getBoundingClientRect();
var x = rect.left + bl + caretL - n.scrollLeft;
var top = rect.top + bt + caretT - n.scrollTop;
return { x: x, top: top, bottom: top + lineHeight };
} catch (_) { return null; }
}
// Inverse: the char offset whose caret is nearest the CSS-px point (x,y). Binary search in reading order
// (offset increases left-to-right, top-to-bottom), so a handle drag maps back to a cursor position. Y is
// first clamped into the field's text rows, so dragging the handle (which sits below the line) or off the
// field keeps the cursor on the nearest line and lets X drive the column — like Android.
function offsetFromPoint(n, x, y){
var len = valOf(n).length;
var first = caretCoords(n, 0), last = caretCoords(n, len);
if (first && y < first.top) y = (first.top + first.bottom) / 2;
else if (last && y > last.bottom) y = (last.top + last.bottom) / 2;
var lo = 0, hi = len;
while (lo < hi) {
var mid = (lo + hi) >> 1, c = caretCoords(n, mid);
if (!c) break;
if (y < c.top) hi = mid;
else if (y > c.bottom) lo = mid + 1;
else if (x < c.x) hi = mid;
else lo = mid + 1;
}
// The search lands on the boundary just RIGHT of x; round to the NEAREST boundary instead (native
// getOffsetForPosition) so tapping the left half of a glyph doesn't advance the caret past it. Only
// compare within the same line (skip when lo sits at a wrap, where lo-1 is on the previous row).
if (lo > 0) {
var cl = caretCoords(n, lo - 1), cr = caretCoords(n, lo);
if (cl && cr && cl.top === cr.top && (x - cl.x) < (cr.x - x)) lo = lo - 1;
}
return lo;
}
// The focused field's bounding box in CSS px (toolbar anchor). When the selection is a bare caret it also
// carries the caret rect (cx/ct/cb) so the host can show a draggable insertion handle.
function fieldGeom(n){
try {
var b = n.getBoundingClientRect();
var g = { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: b.left, sb: b.bottom, ex: b.right, eb: b.bottom, vw: window.innerWidth };
if (!isCE(n)) {
var sel = selOf(n);
if (sel[0] === sel[1]) {
// Bare caret → carry the caret rect so the host shows the insertion handle.
var c = caretCoords(n, sel[0]);
if (c) { g.cx = c.x; g.ct = c.top; g.cb = c.bottom; }
} else {
// Range → carry the start/end caret feet so the host shows draggable selection handles, and
// tighten the box to the selected line span so the toolbar anchors above the selection.
var cs = caretCoords(n, sel[0]), ce = caretCoords(n, sel[1]);
if (cs && ce) {
g.rng = true; // marks a real range so the host keeps these feet through Chrome's collapse fight
g.sx = cs.x; g.sb = cs.bottom; g.ex = ce.x; g.eb = ce.bottom;
g.t = Math.min(cs.top, ce.top); g.b = Math.max(cs.bottom, ce.bottom);
}
}
}
return g;
} catch (_) { return null; }
}
function focusInfo(n){
var t = (n.tagName || '').toUpperCase();
var multiline = isCE(n) || t === 'TEXTAREA';
var inputType = isCE(n) ? 'text' : (t === 'TEXTAREA' ? 'textarea' : (n.type || 'text').toLowerCase());
var sel = selOf(n);
return { type:'ime.focus', inputType: inputType, enterKeyHint: (n.enterKeyHint || ''),
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1] };
multiline: multiline, text: valOf(n), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(n) };
}
// Last selection we either applied (applyState) or already reported, so the asynchronous
// selectionchange our own setSel triggers doesn't echo back to the host as a fresh edit.
var lastSel = null;
// The live non-collapsed field range we protect from Chrome's off-window abandonment (see the
// selectionchange handler). Tracked centrally so handle-extends and select-all keep it current.
var lastFieldRange = null, lastFieldAt = -1;
// When the selection last changed (Chrome-driven OR our own setSel). Forming/re-asserting a selection makes
// the browser auto-scroll the field to reveal it, firing `scroll` events that are NOT a user content scroll.
// The hide-on-scroll path uses this to ignore those: hiding the host overlays on a selection-reveal scroll
// makes the handles/toolbar blink off-and-on every time a selection settles. See onAnyScroll.
var lastSelActivityAt = -1;
function sameSel(a, b){ return !!(a && b && a[0] === b[0] && a[1] === b[1]); }
function noteSel(sel){
lastSel = sel;
if (el && !isCE(el)) {
if (sel[0] !== sel[1]) { lastFieldRange = [sel[0], sel[1]]; lastFieldAt = perfNow(); }
else { lastFieldRange = null; }
}
}
function reportState(){
if (!el) return;
var sel = selOf(el);
lastSel = sel;
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1] });
noteSel(sel);
send({ type:'ime.state', text: valOf(el), selStart: sel[0], selEnd: sel[1], geom: fieldGeom(el) });
}
document.addEventListener('focusin', function(e){
if (isEditable(e.target)) {
el = e.target; inComposition = false; lastSel = selOf(el); send(focusInfo(el));
// Focusing a field clears any page-text selection in the browser. The page selectionchange handler is
// muted while a field is focused (el is set), so it never emits the `active:false` — emit it here, or
// the host's page handles + Copy bar linger ABOVE the field overlays (and, being z-above, steal the
// caret/selection-handle drag so the caret can't be moved).
if (pageSelText) { lastPageRange = null; sendPageSel(false, null); }
// Cancel any in-flight scroll-hide from the page phase so the new field overlays aren't suppressed by a
// stale `scrolling` state (its settle would otherwise keep getting re-armed by the field reveal-scrolls).
scrolling = false; if (scrollTimer) { clearTimeout(scrollTimer); scrollTimer = null; }
// The host shrinks the surface for the keyboard, but also nudge the field into view in case IME
// insets aren't delivered (some hosts) so it never sits behind the keyboard.
try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {}
@@ -296,11 +425,71 @@
}, true);
// The page (its own JS, autofill) changed the field: resync the host keyboard's view of it.
document.addEventListener('input', function(e){ if (e.target === el && !el.__nappletIme) reportState(); }, true);
// Mirror selection changes inside the focused editable to the host. Off-window Chrome abandons a field
// selection by collapsing the caret to one of its endpoints; we re-assert it RIGHT HERE, synchronously,
// the same way the page-text path does — reverting before the collapse paints, so it doesn't blink (the
// old path round-tripped through the host EditText, leaving a visible collapsed frame each cycle). The
// host's own re-assert in RemoteImeView.onPageState stays as a fallback for collapses we don't catch.
document.addEventListener('selectionchange', function(){
if (!el || el.__nappletIme) return;
if (sameSel(selOf(el), lastSel)) return; // our own applyState/setSel echoing back
if (!el || el.__nappletIme) return; // our own applyState/setSel
lastSelActivityAt = perfNow(); // Chrome moved the selection → an imminent reveal-scroll isn't a user scroll
var sel = selOf(el);
if (sameSel(sel, lastSel)) return; // echo of what we just applied
if (!isCE(el) && lastFieldRange && sel[0] === sel[1] &&
(sel[0] === lastFieldRange[0] || sel[0] === lastFieldRange[1]) &&
(perfNow() - lastFieldAt) < 1500) {
// Chrome's off-window abandonment collapsed our live range to an endpoint → snap it back
// synchronously (reverts before paint, so no blink) and keep the window open. The host already
// holds this range, so we don't re-report (which would feed the slow round-trip loop).
el.__nappletIme = true;
setSel(el, lastFieldRange[0], lastFieldRange[1]);
el.__nappletIme = false;
lastSel = selOf(el);
lastFieldAt = perfNow();
return;
}
reportState();
}, true);
// Tap handling on the focused editable. A single tap collapses any selection to a caret at the tap point
// and shows the insertion handle (native; off-window Chrome won't collapse-on-tap itself). A DOUBLE tap
// selects the word — but `click` fires before `dblclick`, so instead of guessing with timing we DEFER the
// collapse and let the real `dblclick` cancel it. This is robust to Chrome's own double-click timing
// (a timing guess raced it and sometimes ate the word selection → "cursor jumps to end of word").
var collapseTimer = null;
function clearCollapse() { if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } }
document.addEventListener('dblclick', function(e){
if (!el || isCE(el)) return;
clearCollapse(); // a real double-tap → don't collapse; keep Chrome's word selection
var s = selOf(el);
if (s[0] !== s[1] && !sameSel(s, lastSel)) reportState(); // report the word only if not already sent
}, true);
document.addEventListener('click', function(e){
if (!el || isCE(el) || e.target !== el) return;
var sel = selOf(el);
if (sel[0] !== sel[1]) {
// Tap landed on a selection. Defer the collapse: if a dblclick follows (within the tap window) it
// cancels this and the word stays selected; otherwise this fires and collapses to the tapped offset.
var x = e.clientX, y = e.clientY;
clearCollapse();
collapseTimer = setTimeout(function(){
collapseTimer = null;
if (!el) return;
var s = selOf(el);
if (s[0] === s[1]) return; // already collapsed
var off = offsetFromPoint(el, x, y);
el.__nappletIme = true;
setSel(el, off, off);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
send({ type:'ime.carettap', geom: fieldGeom(el) });
}, 300);
} else {
// Tap on a bare caret → (re-)show the insertion handle. If a double-tap follows, dblclick selects the
// word and supersedes this.
send({ type:'ime.carettap', geom: fieldGeom(el) });
}
}, true);
function enter(n){
if (!n) return;
@@ -363,12 +552,190 @@
}
setSel(n, msg.selStart, msg.selEnd);
if (!composingActive && inComposition) { inComposition = false; fireComp(n, 'compositionend', d.inserted || ''); }
} finally { n.__nappletIme = false; lastSel = selOf(n); }
} finally { n.__nappletIme = false; noteSel(selOf(n)); }
}
// --- Page (non-editable) text selection re-hosting ---
// Chrome can't present its selection handles/toolbar in the cross-process embedded surface, so a
// long-press on ordinary page text selects a word and then ~60ms later abandons (collapses) it, the
// same way it does inside inputs. Mirror the document selection: re-assert it when it collapses right
// after forming, and report the selected text so the host can show its own Copy bar over the page.
var pageSelText = '', lastPageRange = null, lastPageAt = -1, pageReasserting = false;
// Selection geometry in CSS px (viewport coords). The host maps these to screen px (scale = surface
// width / vw) to draw the toolbar above the selection and a handle at each end. l/t/r/b is the bounding
// box; (sx,sb) the start-caret foot, (ex,eb) the end-caret foot; vw lets the host derive the scale.
function pageGeom(r){
try {
var b = r.getBoundingClientRect();
var sr = r.cloneRange(); sr.collapse(true); var s = sr.getBoundingClientRect();
var er = r.cloneRange(); er.collapse(false); var e = er.getBoundingClientRect();
return { l: b.left, t: b.top, r: b.right, b: b.bottom, sx: s.left, sb: s.bottom, ex: e.left, eb: e.bottom, vw: window.innerWidth };
} catch (_) { return null; }
}
function sendPageSel(active, r){
var text = active ? String(window.getSelection()) : '';
pageSelText = text;
send({ type: 'ime.pagesel', active: active, text: text, geom: active && r ? pageGeom(r) : null });
}
document.addEventListener('selectionchange', function(){
if (el || pageReasserting) return; // selections inside an editable are handled above
lastSelActivityAt = perfNow(); // page selection moved → an imminent reveal-scroll isn't a user scroll
var s = window.getSelection();
if (s && s.rangeCount && !s.isCollapsed) {
var r = s.getRangeAt(0);
lastPageRange = r.cloneRange(); lastPageAt = perfNow();
sendPageSel(true, r);
} else if (lastPageRange && (perfNow() - lastPageAt) < 400) {
pageReasserting = true;
try { s.removeAllRanges(); s.addRange(lastPageRange); } catch (_) {}
pageReasserting = false;
lastPageAt = perfNow();
} else if (pageSelText) {
lastPageRange = null;
sendPageSel(false, null);
}
}, true);
// Host drag of a selection handle: move the dragged edge to the text position under (x,y) CSS px,
// keeping the opposite edge anchored. setBaseAndExtent tolerates either drag direction.
function pageExtend(edge, x, y){
try {
var pt = document.caretRangeFromPoint && document.caretRangeFromPoint(x, y);
var s = window.getSelection();
if (!pt || !s.rangeCount) return;
var cur = s.getRangeAt(0);
var aN, aO;
if (edge === 'start') { aN = cur.endContainer; aO = cur.endOffset; } else { aN = cur.startContainer; aO = cur.startOffset; }
pageReasserting = true;
s.setBaseAndExtent(aN, aO, pt.startContainer, pt.startOffset);
pageReasserting = false;
if (!s.isCollapsed) {
var nr = s.getRangeAt(0);
lastPageRange = nr.cloneRange(); lastPageAt = perfNow();
sendPageSel(true, nr);
}
} catch (_) {}
}
// Word-granularity snapping (native: dragging a word selection's handle extends a word at a time). The
// end handle snaps to the end of the word at/after the offset; the start handle to the start of the word
// at/before it. Whitespace between words extends to the adjacent word so you never stop mid-gap.
function isWordChar(c){ return c != null && /\S/.test(c); }
function wordEndAt(text, off){
var i = off;
while (i < text.length && !isWordChar(text[i])) i++;
while (i < text.length && isWordChar(text[i])) i++;
return i;
}
function wordStartAt(text, off){
var i = off;
while (i > 0 && !isWordChar(text[i - 1])) i--;
while (i > 0 && isWordChar(text[i - 1])) i--;
return i;
}
// Per-drag state for the hybrid word/char handle extend below. `fieldDragWordEnd`/`fieldDragWordStart`
// remember how far the dragged edge has been word-snapped so far this gesture; a >250ms gap between
// `ime.fieldextend` ops (or a switch of edge) means a NEW drag, so we re-baseline to the live selection.
var fieldDragAt = -1, fieldDragEdge = null, fieldDragWordEnd = -1, fieldDragWordStart = -1;
// Host drag of an in-field selection handle: move the dragged edge to the offset under (x,y) CSS px,
// keeping the other edge anchored, clamped so it can't cross the anchor. HYBRID granularity, matching
// native `Editor` word-selection drags (#5): the gesture starts anchored to the current selection edge,
// and as the finger sweeps PAST that word's far boundary it snaps the dragged edge to the next WHOLE word
// (so sweeping across words grabs them whole and never stops mid-gap); moving WITHIN or back from the
// furthest-reached word gives CHARACTER precision (so you can fine-tune to a single character).
function fieldExtend(edge, x, y){
if (!el || isCE(el)) return;
try {
var off = offsetFromPoint(el, x, y);
var text = valOf(el);
var sel = selOf(el);
var now = perfNow();
var fresh = (now - fieldDragAt > 250) || edge !== fieldDragEdge;
fieldDragAt = now; fieldDragEdge = edge;
var s, e;
if (edge === 'start') {
e = sel[1];
if (fresh) fieldDragWordStart = sel[0]; // baseline at the current selection start
if (off < fieldDragWordStart) { s = wordStartAt(text, off); fieldDragWordStart = s; } // swept into a new word → snap whole
else s = off; // within / back from the furthest word → character precision
s = Math.max(0, Math.min(s, e));
} else {
s = sel[0];
if (fresh) fieldDragWordEnd = sel[1]; // baseline at the current selection end
if (off > fieldDragWordEnd) { e = wordEndAt(text, off); fieldDragWordEnd = e; } // swept into a new word → snap whole
else e = off; // within / back from the furthest word → character precision
e = Math.min(text.length, Math.max(e, s));
}
el.__nappletIme = true;
setSel(el, s, e);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
} catch (_) {}
}
// While the page scrolls, host-drawn selection UI (toolbar + handles) would float at stale positions, so
// the host hides it on scroll-start and we re-report fresh geometry on scroll-idle so it reappears in the
// right place — like Android. Only signal when there's a selection to hide (a field range or page text).
var scrolling = false, scrollTimer = null, autoScrolling = false;
// How long after a selection change a scroll is treated as the browser's auto-reveal of that selection
// (not a user content scroll). Generous enough to catch the reveal-scroll that fires a frame or two later.
var SCROLL_SEL_GUARD_MS = 350;
function hasSelectionUi(){ return !!pageSelText || !!(el && (function(s){ return s[0] !== s[1]; })(selOf(el))); }
function onAnyScroll(){
if (autoScrolling) return; // our own drag-to-edge auto-scroll: keep the overlays up, don't hide them
if (!hasSelectionUi()) return;
if ((perfNow() - lastSelActivityAt) < SCROLL_SEL_GUARD_MS) {
// The browser auto-scrolled to reveal a just-changed selection (forming/re-asserting a range scrolls a
// textarea). That's not a user content scroll: hiding here would blink the host overlays off-and-on every
// time a selection settles. Reposition them in place instead (geometry shifted by the reveal-scroll).
// Crucially we do NOT touch the hide-on-scroll timer: if a real scroll-hide is somehow active, these
// reveal-scrolls must not keep re-arming it (that would leave the overlays hidden indefinitely).
if (el) reportState();
else { var sr = window.getSelection(); if (sr && sr.rangeCount && !sr.isCollapsed) sendPageSel(true, sr.getRangeAt(0)); }
return;
}
if (!scrolling) { scrolling = true; send({ type:'ime.scroll', active: true }); }
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(function(){
scrolling = false; scrollTimer = null;
// Refresh geometry FIRST (so overlays reposition), then tell the host to show them again.
if (el) reportState();
else { var s = window.getSelection(); if (s && s.rangeCount && !s.isCollapsed) sendPageSel(true, s.getRangeAt(0)); }
send({ type:'ime.scroll', active: false });
}, 150);
}
document.addEventListener('scroll', onAnyScroll, true); // capture: any scroller, not just the document
window.__nappletImeHandle = function(msg){
if (msg.type === 'ime.set') applyState(msg);
else if (msg.type === 'ime.action') enter(el);
else if (msg.type === 'ime.pageextend') pageExtend(msg.edge, msg.x, msg.y);
else if (msg.type === 'ime.fieldextend') fieldExtend(msg.edge, msg.x, msg.y);
else if (msg.type === 'ime.autoscroll') {
// Host drag of a handle near the surface's top/bottom edge → scroll the content (the textarea if it
// scrolls, else the page) so the selection can keep extending, then re-report geometry so the
// overlays follow. Flagged so our own scroll doesn't trip the hide-on-scroll path above.
var dy = msg.dy || 0;
autoScrolling = true;
try {
if (el && (el.tagName || '').toUpperCase() === 'TEXTAREA') el.scrollTop += dy;
window.scrollBy(0, dy);
} catch (_) {}
if (el) reportState();
else { var s = window.getSelection(); if (s && s.rangeCount && !s.isCollapsed) sendPageSel(true, s.getRangeAt(0)); }
setTimeout(function(){ autoScrolling = false; }, 0);
}
else if (msg.type === 'ime.caretmove') {
if (el && !isCE(el)) {
var off = offsetFromPoint(el, msg.x, msg.y);
el.__nappletIme = true;
setSel(el, off, off);
el.__nappletIme = false;
lastSel = selOf(el);
reportState();
}
}
};
})();
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplethost
import android.content.Context
import android.content.res.Configuration
import android.view.ContextThemeWrapper
/**
* A context that makes a hosted WebView (embedded surface OR full-screen activity) follow the **app** theme
* ("DARK"/"LIGHT") rather than the device.
*
* WebView's dark decision (`prefers-color-scheme` via algorithmic darkening) reads the context's **theme**
* (`?android:attr/isLightTheme`), NOT just the Configuration `uiMode` and an off-window
* `SurfaceControlViewHost` surface context carries neither the host window's theme nor its night mode. So we
* force the night flag in the Configuration AND wrap it in a DayNight theme whose `isLightTheme` then resolves
* from that flag.
*
* (Verified on device with a standalone repro: `createConfigurationContext` alone a night Configuration with
* no theme does NOT flip the renderer; the DayNight `ContextThemeWrapper` is what does it, even across the
* cross-process embedded surface. `setApplicationNightMode` and per-WebView config dispatch do nothing.)
*
* "SYSTEM" (or any unrecognized value) returns [base] unchanged, i.e. follows the device the host already
* resolves SYSTEMDARK/LIGHT before handing the theme down for the embedded surfaces.
*/
internal fun nightThemedContext(
base: Context,
themeType: String,
): Context {
val night =
when (themeType) {
"DARK" -> Configuration.UI_MODE_NIGHT_YES
"LIGHT" -> Configuration.UI_MODE_NIGHT_NO
else -> return base
}
val config =
Configuration(base.resources.configuration).apply {
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or night
}
return ContextThemeWrapper(base.createConfigurationContext(config), android.R.style.Theme_DeviceDefault_DayNight)
}
@@ -35,6 +35,7 @@ import android.os.Messenger
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
@@ -153,7 +154,6 @@ class NappletBrowserActivity : ComponentActivity() {
useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true)
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }
applyNightMode()
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
@@ -161,7 +161,9 @@ class NappletBrowserActivity : ComponentActivity() {
return
}
webView = WebView(this)
// Build the WebView from a context forced to the app theme so its content follows DARK/LIGHT even when
// the device theme differs (WebView reads the context's theme, not the window's — see nightThemedContext).
webView = WebView(nightThemedContext(this, themeType))
configureWebView(webView)
webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
webView.dropSystemBarInsets()
@@ -231,7 +233,6 @@ class NappletBrowserActivity : ComponentActivity() {
super.onResume()
if (this::webView.isInitialized) {
webView.onResume()
webView.resumeTimers()
}
resumed = true
heartbeatHandler.removeCallbacks(heartbeat)
@@ -240,8 +241,11 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onPause() {
if (this::webView.isInitialized) {
// Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is
// process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including
// the embedded ones in NappletBrowserService, which have no resume of their own. That left the
// embed frozen (dead page/connection) after returning from a full-screen excursion.
webView.onPause()
webView.pauseTimers()
}
resumed = false
heartbeatHandler.removeCallbacks(heartbeat)
@@ -251,7 +255,17 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onDestroy() {
runCatching { unbindService(brokerConnection) }
if (this::webView.isInitialized) webView.destroy()
if (this::webView.isInitialized) {
// Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to
// the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER
// (embedded) WebViews living in this `:napplet` process: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads
// returning empty (`value == ""` on a field that visibly shows text), dead selection-highlight paint,
// and broken IME — all after a full-screen excursion returns to an embed. (`destroy()` requires the
// view to be removed from the hierarchy first; see WebView.destroy() docs.)
webView.stopLoading()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
super.onDestroy()
}
@@ -644,14 +658,6 @@ class NappletBrowserActivity : ComponentActivity() {
addView(ProgressBar(this@NappletBrowserActivity))
}
private fun applyNightMode() {
val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager
when (themeType) {
"DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES
"LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO
}
}
private fun resolveThemeColor(attr: Int): Int {
val tv = android.util.TypedValue()
theme.resolveAttribute(attr, tv, true)
@@ -77,6 +77,34 @@ object NappletBrowserContract {
*/
const val MSG_CONSOLE_LOG = 11
/**
* Client provider: capture a magnified slice of the live page for the native-style selection loupe.
* Host-side `PixelCopy` of the sandboxed surface returns `ERROR_SOURCE_NO_DATA` (the WebView pixels live
* in a child SurfaceControl the host never draws into), so we capture INSIDE the provider where the
* WebView is a real in-window view. Carries [KEY_MAG_X]/[KEY_MAG_Y] (surface px center),
* [KEY_MAG_BOX_W]/[KEY_MAG_BOX_H] (source rectangle, px), [KEY_MAG_ZOOM], and [KEY_MAG_REQ_T] (the
* client's `nanoTime` send stamp, echoed back so the client can drop stale out-of-order frames).
*/
const val MSG_MAGNIFIER_REQUEST = 12
/**
* Provider client: the captured loupe frame. Carries [KEY_MAG_BYTES] (a PNG well under the Binder
* limit), [KEY_MAG_W]/[KEY_MAG_H], [KEY_MAG_CAPTURE_MS] (provider-side draw+encode time), and the echoed
* [KEY_MAG_REQ_T] so the client matches it to its request / drops stale frames.
*/
const val MSG_MAGNIFIER_FRAME = 13
const val KEY_MAG_X = "magX"
const val KEY_MAG_Y = "magY"
const val KEY_MAG_BOX_W = "magBoxW"
const val KEY_MAG_BOX_H = "magBoxH"
const val KEY_MAG_ZOOM = "magZoom"
const val KEY_MAG_REQ_T = "magReqT"
const val KEY_MAG_BYTES = "magBytes"
const val KEY_MAG_W = "magW"
const val KEY_MAG_H = "magH"
const val KEY_MAG_CAPTURE_MS = "magCaptureMs"
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
@@ -25,6 +25,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -33,6 +35,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
@@ -51,6 +54,7 @@ import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import org.json.JSONObject
import java.io.ByteArrayOutputStream
/**
* Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the
@@ -139,14 +143,6 @@ class NappletBrowserService : Service() {
super.onDestroy()
}
private fun applyNightMode(themeType: String) {
val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager
when (themeType) {
"DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES
"LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO
}
}
private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] }
private fun onClientMessage(msg: Message): Boolean {
@@ -164,7 +160,6 @@ class NappletBrowserService : Service() {
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE),
themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" },
)
applyNightMode(tab.themeType)
tabs[sessionId] = tab
// Bind the broker once; a re-sent MSG_CREATE_SESSION (e.g. client reconnect) must not
// leak a second binding.
@@ -190,11 +185,56 @@ class NappletBrowserService : Service() {
// reload the one the user toggled.
applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) { tab.webView?.reload() }
}
NappletBrowserContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
else -> return false
}
return true
}
// One reusable output bitmap per tab would be ideal, but loupe size is fixed per drag; createBitmap each
// frame is cheap next to the draw. Source rect is in view px (== surface px, the SCVH is 1:1).
private fun onMagnifierRequest(msg: Message) {
val tab = tabFor(msg) ?: return
val wv = tab.webView ?: return
val data = msg.data ?: return
val cx = data.getFloat(NappletBrowserContract.KEY_MAG_X)
val cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y)
val boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
val boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
val zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
val reqT = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T)
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
val t0 = SystemClock.elapsedRealtimeNanos()
val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(tab.bgColor)
// Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap.
canvas.scale(zoom, zoom)
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
wv.draw(canvas)
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
bitmap.recycle()
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
val reply =
Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes)
putInt(NappletBrowserContract.KEY_MAG_W, outW)
putInt(NappletBrowserContract.KEY_MAG_H, outH)
putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT)
}
}
runCatching { tab.clientMessenger?.send(reply) }
}
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
private fun replyWithAdapter(tab: BrowserTab) {
val adapter = NappletBrowserUiAdapter(this, tab.sessionId)
@@ -218,7 +258,7 @@ class NappletBrowserService : Service() {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId")
val wv = WebView(context)
val wv = WebView(nightThemedContext(context, tab.themeType))
configureWebView(wv, tab)
// Theme the pre-load background so a blank/loading page shows Amethyst's background, not white.
wv.setBackgroundColor(tab.bgColor)
@@ -84,6 +84,29 @@ object NappletEmbedContract {
*/
const val MSG_LOAD_STATE = 15
/**
* Client provider: capture a magnified slice of the live page for the selection loupe. Host-side
* `PixelCopy` of the sandbox surface returns `ERROR_SOURCE_NO_DATA` (the WebView pixels live in a child
* SurfaceControl), so capture happens here, in the provider, where the WebView is a real in-window view.
* Carries [KEY_MAG_X]/[KEY_MAG_Y] (surface px center), [KEY_MAG_BOX_W]/[KEY_MAG_BOX_H] (source rect, px),
* [KEY_MAG_ZOOM], and [KEY_MAG_REQ_T] (echoed send stamp). Mirrors the browser path.
*/
const val MSG_MAGNIFIER_REQUEST = 16
/** Provider → client: the captured loupe frame — [KEY_MAG_BYTES] PNG, [KEY_MAG_W]/[KEY_MAG_H], [KEY_MAG_CAPTURE_MS], echoed [KEY_MAG_REQ_T]. */
const val MSG_MAGNIFIER_FRAME = 17
const val KEY_MAG_X = "magX"
const val KEY_MAG_Y = "magY"
const val KEY_MAG_BOX_W = "magBoxW"
const val KEY_MAG_BOX_H = "magBoxH"
const val KEY_MAG_ZOOM = "magZoom"
const val KEY_MAG_REQ_T = "magReqT"
const val KEY_MAG_BYTES = "magBytes"
const val KEY_MAG_W = "magW"
const val KEY_MAG_H = "magH"
const val KEY_MAG_CAPTURE_MS = "magCaptureMs"
const val KEY_CORE_LIB_INFO = "coreLibInfo"
const val KEY_CAN_GO_BACK = "canGoBack"
const val KEY_IS_LOADING = "isLoading"
@@ -205,8 +205,6 @@ class NappletHostActivity : ComponentActivity() {
return
}
applyNightMode()
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
finish()
@@ -230,7 +228,9 @@ class NappletHostActivity : ComponentActivity() {
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after
// it. Binding the broker early overlaps too. The WebView is attached once the probe succeeds.
webView = WebView(this)
// Built from a context forced to the app theme so its content follows DARK/LIGHT regardless of the
// device theme (WebView reads the context's theme, not the window's — see nightThemedContext).
webView = WebView(nightThemedContext(this, themeType))
hardenWebView(webView)
// Theme the WebView's pre-paint background to the app's so it doesn't flash white when the shell
// mounts. This activity has a themed context, so it resolves the color locally (no IPC needed).
@@ -321,7 +321,6 @@ class NappletHostActivity : ComponentActivity() {
super.onResume()
if (this::webView.isInitialized) {
webView.onResume()
webView.resumeTimers()
}
// Launching this :napplet-process surface backgrounded the main process; tell the broker to
// hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and
@@ -335,8 +334,10 @@ class NappletHostActivity : ComponentActivity() {
// sign/decrypt/pay request whose consent prompt would surface over (and be confused with)
// Amethyst's own UI. Requests only happen while the user is looking at this napplet.
if (this::webView.isInitialized) {
// webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't
// fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes
// EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume.
webView.onPause()
webView.pauseTimers()
}
// No longer foreground: stop renewing and let the main process resume normal background scaling.
resumed = false
@@ -384,6 +385,11 @@ class NappletHostActivity : ComponentActivity() {
runCatching { unbindService(brokerConnection) }
keyActions.clear()
if (this::webView.isInitialized) {
// Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess
// renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process
// (dead DNS, empty DOM reads, dead selection paint, broken IME). See NappletBrowserActivity.
webView.stopLoading()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
super.onDestroy()
@@ -803,14 +809,6 @@ class NappletHostActivity : ComponentActivity() {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
private fun applyNightMode() {
val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager
when (themeType) {
"DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES
"LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO
}
}
private fun resolveThemeColor(attr: Int): Int {
val tv = TypedValue()
theme.resolveAttribute(attr, tv, true)
@@ -25,6 +25,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -33,6 +35,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.webkit.WebResourceError
@@ -57,6 +60,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executor
/**
@@ -168,34 +172,22 @@ class NappletHostService : Service() {
}
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletEmbedContract.MSG_PAUSE ->
tabFor(msg)?.webView?.let {
it.onPause()
it.pauseTimers()
}
NappletEmbedContract.MSG_RESUME ->
tabFor(msg)?.webView?.let {
it.onResume()
it.resumeTimers()
}
// onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call
// pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in
// `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one.
NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.webView?.onPause()
NappletEmbedContract.MSG_RESUME -> tabFor(msg)?.webView?.onResume()
NappletEmbedContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
tab.bridgeReplyProxy?.postMessage(payload)
}
NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg)
else -> return false
}
return true
}
private fun applyNightMode(themeType: String) {
val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager
when (themeType) {
"DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES
"LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO
}
}
private fun buildTab(msg: Message): NappletTab? {
val data = msg.data ?: return null
val sessionId = data.getString(NappletEmbedContract.KEY_SESSION_ID) ?: return null
@@ -225,10 +217,55 @@ class NappletHostService : Service() {
themeType = data.getString(NappletHostContract.EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" },
declaredDomains = declaredDomains,
)
applyNightMode(tab.themeType)
return tab
}
/**
* Draw a zoomed slice of the live WebView (the loupe content) and ship it back as a PNG. The WebView is a
* real in-window view here in the provider, so its software draw renders real DOM pixels unlike host-side
* `PixelCopy` of the sandbox surface. Mirror of the browser path. Runs on the main looper (`WebView.draw`).
*/
private fun onMagnifierRequest(msg: Message) {
val tab = tabFor(msg) ?: return
val wv = tab.webView ?: return
val data = msg.data ?: return
val cx = data.getFloat(NappletEmbedContract.KEY_MAG_X)
val cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y)
val boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024)
val boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024)
val zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f)
val reqT = data.getLong(NappletEmbedContract.KEY_MAG_REQ_T)
val outW = (boxW * zoom).toInt().coerceAtLeast(1)
val outH = (boxH * zoom).toInt().coerceAtLeast(1)
val t0 = SystemClock.elapsedRealtimeNanos()
val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(tab.bgColor)
canvas.scale(zoom, zoom)
canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f))
wv.draw(canvas)
val baos = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos)
val bytes = baos.toByteArray()
bitmap.recycle()
val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0
val reply =
Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply {
this.data =
Bundle().apply {
putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes)
putInt(NappletEmbedContract.KEY_MAG_W, outW)
putInt(NappletEmbedContract.KEY_MAG_H, outH)
putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs)
putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT)
}
}
runCatching { tab.clientMessenger?.send(reply) }
}
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
private fun replyWithAdapter(tab: NappletTab) {
val adapter = NappletHostUiAdapter(this, tab.sessionId)
@@ -252,7 +289,7 @@ class NappletHostService : Service() {
// The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather
// than build a WebView that no tab tracks (it would leak).
val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId")
val wv = WebView(context)
val wv = WebView(nightThemedContext(context, tab.themeType))
val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier))
val effectiveProxy = if (tab.useTor) tab.proxyPort else -1
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true)
+54
View File
@@ -0,0 +1,54 @@
# 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 resulting `value`/selection — to catch erase, caret-jump, and
focus-transfer regressions;
- **paint latency** (`requestAnimationFrame` after 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
1. Serve this directory over HTTP from your dev machine:
```bash
cd tools/ime-test && python3 -m http.server 8765
```
2. Reach it from the device/emulator:
- **Emulator:** the page is at `http://10.0.2.2:8765` (`10.0.2.2` is the
emulator's alias for the host loopback).
- **Physical device (USB):** `adb reverse tcp:8765 tcp:8765`, then the page is
at `http://localhost:8765`.
3. 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 handles `http`/`https`, so it loads into the
`:napplet` SurfaceControlViewHost 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).
## Reading the log
- `INPUT … val=… sel=…` right after a keystroke with the right value = no erase.
- `PAINT-LATENCY Nms` spiking 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.
- `HEARTBEAT` lines changing while idle = spontaneous focus/selection drift.
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IME Test</title>
<style>
body { font-family: sans-serif; padding: 16px; background:#f4f4fa; }
h2 { margin: 24px 0 6px; }
input, textarea { width: 100%; font-size: 18px; padding: 12px; box-sizing: border-box; border: 2px solid #88a; border-radius: 8px; }
#log { white-space: pre-wrap; font: 11px monospace; background:#111; color:#0f0; padding:8px; height:200px; overflow:auto; margin-top:16px; border-radius:6px; }
</style>
</head>
<body>
<h2>Plain text input</h2>
<input id="inp" type="text" placeholder="type here" value="hello world">
<h2>Textarea</h2>
<textarea id="ta" rows="2" placeholder="type here">hello world</textarea>
<div id="log"></div>
<!-- Tall spacer so the page scrolls — lets us verify the host hides selection UI during scroll. -->
<div style="height:2000px; padding-top:16px; color:#888;">scroll region — drag up/down here</div>
<script>
(function(){
var t0 = performance.now();
function ts(){ return (performance.now() - t0).toFixed(0); }
var logEl = document.getElementById('log');
function log(m){
var line = '[ImeDiag] +' + ts() + 'ms ' + m;
try { console.log(line); } catch(_){}
if (logEl) { logEl.textContent += line + '\n'; logEl.scrollTop = logEl.scrollHeight; }
}
function selOf(n){ return (n && n.selectionStart != null) ? (n.selectionStart + '..' + n.selectionEnd) : '?'; }
function active(){ var a = document.activeElement; return a ? (a.tagName + '#' + (a.id||'')) : 'none'; }
['inp','ta'].forEach(function(id){
var n = document.getElementById(id);
n.addEventListener('focus', function(){ log(id + ' FOCUS hasFocus=' + document.hasFocus()); });
n.addEventListener('blur', function(){ log(id + ' BLUR'); });
n.addEventListener('keydown', function(e){ log(id + ' keydown key=' + e.key + ' isComposing=' + e.isComposing); });
n.addEventListener('beforeinput', function(e){ log(id + ' beforeinput type=' + e.inputType + ' data=' + JSON.stringify(e.data) + ' isComposing=' + e.isComposing); });
n.addEventListener('input', function(e){
log(id + ' INPUT type=' + e.inputType + ' data=' + JSON.stringify(e.data) + ' isComposing=' + e.isComposing + ' val=' + JSON.stringify(n.value) + ' sel=' + selOf(n));
// Objective paint-latency: how long until the NEXT frame is produced after this DOM change.
var t = performance.now();
requestAnimationFrame(function(){ log(id + ' PAINT-LATENCY ' + (performance.now() - t).toFixed(0) + 'ms (DOM change -> next frame)'); });
});
n.addEventListener('compositionstart', function(e){ log(id + ' compositionSTART data=' + JSON.stringify(e.data)); });
n.addEventListener('compositionupdate', function(e){ log(id + ' compositionUPDATE data=' + JSON.stringify(e.data)); });
n.addEventListener('compositionend', function(e){ log(id + ' compositionEND data=' + JSON.stringify(e.data)); });
n.addEventListener('select', function(){ log(id + ' SELECT event sel=' + selOf(n)); });
});
document.addEventListener('selectionchange', function(){
var a = document.activeElement;
if (a && (a.id === 'inp' || a.id === 'ta')) log('selectionchange ' + a.id + ' sel=' + selOf(a) + ' hasFocus=' + document.hasFocus());
}, true);
window.addEventListener('focus', function(){ log('WINDOW focus hasFocus=' + document.hasFocus()); }, true);
window.addEventListener('blur', function(){ log('WINDOW blur hasFocus=' + document.hasFocus()); }, true);
document.addEventListener('visibilitychange', function(){ log('visibilitychange hidden=' + document.hidden); });
// Heartbeat: detect any spontaneous oscillation of focus/selection while idle.
var last = '';
setInterval(function(){
var a = document.activeElement;
var snap = 'hasFocus=' + document.hasFocus() + ' vis=' + document.visibilityState + ' active=' + active() + ' sel=' + (a && a.selectionStart != null ? selOf(a) : '-');
if (snap !== last) { last = snap; log('HEARTBEAT ' + snap); }
}, 250);
// Long-task observer: catches any >50ms block of the WebView main thread and its attribution.
try {
new PerformanceObserver(function(list){
list.getEntries().forEach(function(e){
var attr = (e.attribution && e.attribution[0]) ? (e.attribution[0].name + '/' + e.attribution[0].containerType) : '';
log('LONGTASK ' + e.duration.toFixed(0) + 'ms name=' + e.name + ' ' + attr);
});
}).observe({ entryTypes: ['longtask'] });
} catch(_) { log('no longtask observer'); }
// Main-thread block detector via MessageChannel (fires a macrotask ASAP each tick; if it's late, main was blocked).
var mc = new MessageChannel();
var tickExpected = performance.now();
mc.port1.onmessage = function(){
var now = performance.now(), late = now - tickExpected;
if (late > 150) log('MAINTHREAD BLOCKED ~' + late.toFixed(0) + 'ms');
tickExpected = now + 8;
setTimeout(function(){ mc.port2.postMessage(0); }, 8);
};
mc.port2.postMessage(0);
log('test page loaded vis=' + document.visibilityState);
})();
</script>
</body>
</html>