Skip to content

Commit 14d0cef

Browse files
committed
fix(android): eliminate Invalid window token crash on SCVH teardown + fix Android 11 BLUR fallback
Production crash from downstream apps' Play Console (NEAR Mobile and others): java.lang.IllegalArgumentException: Invalid window token (never added or removed already) at android.view.WindowlessWindowManager.relayout at android.view.ViewRootImpl.relayoutWindow / performTraversals at android.view.Choreographer.doFrame The SCVH path introduced in v0.1.3 (snapshot-race fix) creates a `WindowlessWindowManager`-backed window through `SurfaceControlViewHost`. Teardown is racy: `host.release()` removes the WWM token via an asynchronously-dispatched `doDie()` (`MSG_DIE`), and any `TraversalRunnable` already in the SCVH ViewRootImpl's Choreographer queue can fire after the token is removed, throwing the crash from `Looper.loop` — outside any try/catch. v0.1.5 added reflective `unscheduleScvhTraversals` before release, which cancels queued runnables when reflection is available. Android 14+ hidden-API enforcement blocks the probe on a growing share of devices, leaving the crash unfixed there. This commit closes the crash class deterministically while preserving the SurfaceFlinger-direct alpha toggle that wins the Home-press snapshot race on every device that supports SCVH. Layered defences in `detachCoverView` + `tryAttachCoverViaScvh`: - `FreezableFrameLayout` cover-content root whose `requestLayout()` and `invalidate()` no-op while a `frozen` flag is set. Set first in `detachCoverView`; from that point no `requestLayout` reaches `ViewRootImpl.scheduleTraversals`, so no new TraversalRunnable can be queued during teardown. - Snapshot-then-null-out of shared state at the top of `detachCoverView`, plus a `coverDetaching` re-entrance guard. Any synchronous re-entrant call (animation cancel, dispatchDetached) sees cleared fields and bails before double-removing or double-releasing. - Cancel SCVH traversals BEFORE `removeView` via `unscheduleScvhTraversals` (best-effort; latches off on permanent reflection failure). - `WindowManager.removeViewImmediate` so `dispatchDetachedFromWindow` runs inline while the freeze is active. Falls back to async `removeView` on OEM impls that reject immediate removal in transitional states. - `setCoverVisibility` validity-checks `view.windowToken != null` before `updateViewLayout` — same WMS/WWM path that throws when the token is gone. - `deferredReleaseScvh` schedules `safeReleaseScvh` to run AFTER the next Choreographer frame's traversal callbacks complete. Uses `Choreographer.postFrameCallback` (animation phase) → nested `mainHandler.post`. `ViewRootImpl.mTraversalScheduled` guarantees at most one queued TraversalRunnable per ViewRootImpl; a single frame's wait drains the queue. Any pre-queued runnable fires with the WWM token still valid; release happens after, removing the token only when nothing is left to relayout. Wired into all 6 release call sites (5 in `tryAttachCoverViaScvh` recovery branches + `detachCoverView`). - API 30 + `INTERNAL_SYSTEM_WINDOW` pre-check. On Android 11, `WindowManagerService.addWindow` enforces this signature-level permission for the SCVH path. `host.setView` throws `SecurityException` AFTER `ViewRootImpl.setView` has already called `requestLayout()`, queuing a TraversalRunnable that fires the same vsync and crashes because the token was never registered with the WWM. The deferred release can't help — the runnable fires in the same vsync's TRAVERSAL phase, before our queued Handler message. Skipping SCVH entirely when the permission isn't granted is the only safe path. Scoped to API 30 only; Android 12+ dropped the check, so SCVH and the snapshot-race fix run unmodified on every modern device. Also fixes `CoverBlurRenderer` on API < 31: `RenderEffect` doesn't exist, and the previous fallback dropped the captured bitmap and painted a flat ~80% white tint, leaving underlying app content fully readable through the cover (broken privacy on every Android 11 host). Now captures at 1/12 in each dimension (1/144 pixels) — small enough that ImageView's bilinear filter at draw time produces a frosted-glass smudge — and layers the style tint as foreground, matching the visual contract of the API >= S path. Verified on Favvy_Android_30 (API 30, reflection-blocked, permission-restricted SCVH) and Pixel_9 (API 34+, reflection-blocked, SCVH-friendly): no `IllegalArgumentException: Invalid window token` in `logcat *:E` across 9/9 maestro flows + rapid Home/app-switcher cycles + rapid enable/disable toggles. App PID stable. SCVH fast alpha (`broadcast: fast scvh=true dt=0ms`) persists across detaches on devices that support it. Android 11 BLUR mode shows real smudged content with style tint in recents-thumbnail view.
1 parent 1650639 commit 14d0cef

2 files changed

Lines changed: 502 additions & 36 deletions

File tree

android/src/main/java/com/margelo/nitro/cover/CoverBlurRenderer.kt

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ internal const val BLUR_VIEW_TAG = "CoverBlurView"
1818
/// scale (radius is in source pixels).
1919
private const val BLUR_MAX_RADIUS = 50f
2020

21+
/// Capture scale for the GPU-blur path (API >= S). 1/4 in each
22+
/// dimension cuts allocation 16× and the upscale on display is hidden
23+
/// inside the `RenderEffect` blur.
24+
private const val GPU_BLUR_CAPTURE_SCALE = 0.25f
25+
26+
/// Capture scale for the API < S downscale-upscale fallback. Much
27+
/// smaller — 1/12 in each dimension (so 1/144 pixels) — because
28+
/// there's no GPU blur to mask the upscaled blockiness. The bilinear
29+
/// filter applied at draw time produces a smudge that approximates
30+
/// the iOS UIBlurEffect look well enough to keep underlying app
31+
/// content unreadable, even when the style tint is light.
32+
private const val FALLBACK_BLUR_CAPTURE_SCALE = 0.083f
33+
2134
internal object CoverBlurRenderer {
2235
/// Capture the topmost host view (e.g. a Modal Dialog's decor when
2336
/// one is open) at 1/4 scale, blur it via `RenderEffect`, and apply
@@ -49,17 +62,42 @@ internal object CoverBlurRenderer {
4962
exclude = target.rootView,
5063
exclude2 = alsoExclude,
5164
) ?: activity.window?.decorView ?: return
52-
// 1/4 scale: cuts the bitmap allocation 16× and the GPU upscale on
53-
// display is hidden behind the blur.
54-
val bitmap = captureViewBitmap(source, scale = 0.25f) ?: return
5565
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
66+
// API >= S: real GPU blur via RenderEffect. Capture at 1/4 — the
67+
// RenderEffect upscale hides any pixelation.
68+
val bitmap = captureViewBitmap(source, scale = GPU_BLUR_CAPTURE_SCALE) ?: return
5669
target.setImageBitmap(bitmap)
5770
applyRenderEffect(target, intensity)
5871
val tint = style.tintColor()
5972
target.foreground = if (tint != Color.TRANSPARENT) ColorDrawable(tint) else null
6073
} else {
61-
target.setImageDrawable(null)
62-
target.setBackgroundColor(style.fallbackColor())
74+
// API < S (Android 11 and below): RenderEffect doesn't exist.
75+
// The old fallback dropped the bitmap and painted a flat
76+
// semi-transparent tint, which left the underlying app fully
77+
// readable through ~20% transparency — broken privacy for any
78+
// host that runs on Android 11. Instead, capture VERY small
79+
// (1/144 pixels) and let ImageView's bilinear filter smudge it
80+
// back to full size during draw. That gives a frosted-glass
81+
// approximation good enough to obscure text while keeping the
82+
// colour palette of the underlying content (so the cover doesn't
83+
// jump abruptly from "app" to "flat block of colour"). Tint and
84+
// intensity then layer on top exactly like the API >= S path,
85+
// so the visual contract across versions is consistent.
86+
val bitmap = captureViewBitmap(source, scale = FALLBACK_BLUR_CAPTURE_SCALE)
87+
if (bitmap != null) {
88+
target.setImageBitmap(bitmap)
89+
val tint = style.tintColor()
90+
target.foreground = if (tint != Color.TRANSPARENT) ColorDrawable(tint) else null
91+
target.setBackgroundColor(Color.TRANSPARENT)
92+
} else {
93+
// Source view had no laid-out size (rare; e.g. first frame
94+
// before measure). Fall back to the original flat tint so the
95+
// privacy cover still hides content, just without the
96+
// smudged-content look.
97+
target.setImageDrawable(null)
98+
target.foreground = null
99+
target.setBackgroundColor(style.fallbackColor())
100+
}
63101
}
64102
}
65103

0 commit comments

Comments
 (0)