Skip to content

Commit 1af22e5

Browse files
authored
Merge pull request #6 from amillez/fix/android-api31-scvh-queued-traversal
fix(android): API 31+ Invalid window token crash + v0.1.9
2 parents 5d43f8c + c315d3b commit 1af22e5

3 files changed

Lines changed: 108 additions & 105 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.1.9 - 2026-06-02
4+
5+
- Android: eliminate the `IllegalArgumentException: Invalid window token (never added or removed already)` crash from `WindowlessWindowManager.relayout` on Android 12 (API 31) — same crash class as v0.1.6 fixed for Android 11 (API 30), but re-surfacing on API 31 because v0.1.6's `INTERNAL_SYSTEM_WINDOW` pre-check was scoped `SDK_INT == 30` only. AOSP enforces the same `INTERNAL_SYSTEM_WINDOW` permission gate for SCVH's `addToDisplay` path on Android 12 (API 31) and Android 12L (API 32), so `host.setView()` throws `SecurityException` on those releases too — and `ViewRootImpl.setView` queues a `TraversalRunnable` via `requestLayout()` BEFORE the throw, so by the time the catch block runs there's a runnable in the SCVH's Choreographer queue that fires at the next vsync (TRAVERSAL phase) against a token that was never registered with the WWM. Reproduced 1:1 on the Pixel 6 / Android 12 (API 31) emulator with the user-reported stack trace. Two-layer fix:
6+
- Extended the `INTERNAL_SYSTEM_WINDOW` pre-check to `SDK_INT in 30..32` (Android 11, 12, 12L). On those releases SCVH is skipped entirely, no `setView` attempt, no queued runnable, no crash. AOSP relaxed the requirement starting in API 33 (Android 13), so SCVH and the SurfaceFlinger-direct alpha toggle keep working unmodified on every Android 13+ device — confirmed on Pixel_9 / Android 16 (API 37) where the maestro regression suite still passes 9/9.
7+
- New `failScvhAttach(host, reason)` helper invoked from all five SCVH attach recovery branches in `tryAttachCoverViaScvh` (setView, null surfacePackage, initial setAlpha, setChildSurfacePackage, addView). It now SYNCHRONOUSLY calls `unscheduleScvhTraversals(host)` to cancel any TraversalRunnable already queued by the failed `setView` BEFORE returning to the caller — main-thread synchronous, drains the queue before the next vsync fires. The previous `deferredReleaseScvh` (Choreographer animation phase → `mainHandler.post`) was structurally too late: the queued runnable runs in the TRAVERSAL phase, strictly before any Handler message scheduled during the same vsync. This synchronous cancel is the structural defence that protects every API where SCVH might unexpectedly fail (not just the API 30..32 range covered by the pre-check) — observed value: cold-boot Pixel emulator quirks on Android 14 where SCVH intermittently fails, and any future Android version that might re-introduce a similar restriction.
8+
9+
`minSdkVersion=23`, public API, and iOS behavior unchanged.
10+
311
## 0.1.8 - 2026-06-01
412

513
- Android: complete the Samsung One UI Recents-cover fix from v0.1.7. The instance-based `exclude2 = coverContent` exclusion in `topmostHostViewFor` only filtered the *current* cover content; on Samsung One UI (Galaxy A05 / SM-A057F, Android 14, One UI 6/7), rapid `setColor` / `setImage` / `enable` JS calls during `LockCover` mount trigger 2-3 sequential attach cycles (`ensurePreMounted` → `refreshCoverContentIfMounted`), and the previous attach's `FreezableFrameLayout` lingers briefly in `WindowManagerGlobal.mViews` after teardown. The walk picked the stale instance as "topmost", `addCover` tried to attach as a sub-window of an invalidated token, and `WindowManager.addView` threw `BadTokenException: Unable to add window — token … is not valid; is your activity running?` on the third attach in the burst. The result: cover never appeared in the Recents thumbnail. Fix: add a class-based filter in `CoverWindowAttachment.topmostHostViewFor` that rejects any `FreezableFrameLayout` regardless of instance, so stale entries from previous attach cycles can't be mistaken for the topmost host. `FreezableFrameLayout` was made `internal` (package-visible) to keep the type reachable from `CoverWindowAttachment`. The instance-match `exclude2 = coverContent` is preserved as a fast-path. On every non-Samsung device the SCVH-hosted content never appears in `WindowManagerGlobal.mViews` to begin with, so the new filter is a no-op. Verified end-to-end on Samsung Galaxy A05 (cover paints in Recents with `#5F8AFA` background and splash icon, no `BadTokenException`), Pixel_9 emulator (9/9 maestro flows green, same baseline as v0.1.6), and Favvy_Android_30 / API 30 (5× home/app-switcher + 10× rapid toggle stress, zero crashes, API 30 `INTERNAL_SYSTEM_WINDOW` pre-check still fires).

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

Lines changed: 99 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -869,10 +869,10 @@ class HybridCover : HybridCoverSpec() {
869869
// SCVH is broken on this device. See `scvhDisabled` doc above.
870870
if (scvhDisabled) return false
871871

872-
// Pre-check (Android 11 / API 30 only): on this AOSP release the
873-
// SCVH path through `WindowManagerService.addWindow` enforces
874-
// `INTERNAL_SYSTEM_WINDOW`, a signature-level permission no
875-
// ordinary app holds. `host.setView()` throws
872+
// Pre-check (Android 11 / 12 / 12L — API 30 to 32): on these AOSP
873+
// releases the SCVH path through `WindowManagerService.addWindow`
874+
// enforces `INTERNAL_SYSTEM_WINDOW`, a signature-level permission
875+
// no ordinary app holds. `host.setView()` throws
876876
//
877877
// SecurityException: Requires INTERNAL_SYSTEM_WINDOW permission
878878
//
@@ -881,42 +881,42 @@ class HybridCover : HybridCoverSpec() {
881881
// `ViewRootImpl.setView` calls `requestLayout()` BEFORE
882882
// `addToDisplay`, so by the time the exception lands a
883883
// `TraversalRunnable` is already queued on the SCVH's
884-
// Choreographer. At the next vsync — the very same vsync our
885-
// `deferredReleaseScvh` schedules against — that runnable runs in
886-
// the TRAVERSAL phase (before our queued Handler message in the
887-
// commit/post-frame slot) and calls
888-
// `WindowlessWindowManager.relayout` against a token that was
889-
// never registered, throwing
884+
// Choreographer. At the next vsync that runnable runs in the
885+
// TRAVERSAL phase — strictly before any Handler messages our
886+
// recovery path could schedule (`deferredReleaseScvh` posts via
887+
// `Choreographer.postFrameCallback` → animation phase →
888+
// `mainHandler.post`, which lands after the traversal phase
889+
// completes) — and calls `WindowlessWindowManager.relayout`
890+
// against a token that was never registered, throwing
890891
// `IllegalArgumentException: Invalid window token (never added or
891-
// removed already)` from `Looper.loop` — the same production
892-
// crash class this PR was opened to fix, routed via the SCVH
893-
// recovery path instead of teardown.
892+
// removed already)` from `Looper.loop`. The crash signature in
893+
// production matches this exactly (reported on Pixel 6 / Android
894+
// 12 — reproduced 1:1 on the emulator).
894895
//
895-
// Reflective unschedule can't save us (reflection is blocked on
896-
// these same devices), and the queued runnable fires BEFORE the
897-
// deferred release does on next vsync. The only path that
898-
// prevents BOTH (a) the failed setView AND (b) the queued-
899-
// runnable crash is to skip SCVH entirely on devices where the
900-
// permission is enforced.
896+
// The catch block below now also calls `unscheduleScvhTraversals`
897+
// SYNCHRONOUSLY to cancel that queued runnable before the next
898+
// vsync — that's the structural fix that protects every API where
899+
// setView might unexpectedly fail. This pre-check is an
900+
// optimization on top: known-affected Android versions skip the
901+
// wasted SCVH attempt entirely.
901902
//
902-
// Scope: API 30 ONLY. Android 12+ (API 31+) dropped the
903-
// `INTERNAL_SYSTEM_WINDOW` check for the SCVH addToDisplay path,
904-
// so SCVH works for ordinary apps on every Android 12+ device we
905-
// know about. Running the permission check on those would falsely
906-
// disable SCVH for everyone (the permission is signature-level
907-
// and never granted to user apps), reintroducing the snapshot
908-
// race fix's intended target — exactly the regression we'd be
909-
// fixing.
903+
// Scope: API 30..32 (Android 11, 12, 12L). AOSP relaxed the
904+
// `INTERNAL_SYSTEM_WINDOW` requirement for SCVH's `addToDisplay`
905+
// path starting in Android 13 (API 33), so SCVH works for
906+
// ordinary apps on every Android 13+ device we know about.
907+
// Empirically confirmed working on Pixel_9 / Android 16 (API 37)
908+
// — the maestro regression suite passes 9/9 there with the
909+
// SCVH SurfaceFlinger-direct alpha toggle active.
910910
//
911-
// We accept losing the SurfaceFlinger-direct alpha toggle on
912-
// Android 11 devices in exchange for never crashing the process.
913-
// The legacy `view.alpha` path still works, just with a slightly
914-
// higher Home-press snapshot-race window.
915-
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R &&
911+
// We accept losing that SF-direct toggle on Android 11/12/12L
912+
// devices in exchange for never crashing the process. The legacy
913+
// `view.alpha` path still works, just with a slightly higher
914+
// Home-press snapshot-race window.
915+
if (Build.VERSION.SDK_INT in Build.VERSION_CODES.R..Build.VERSION_CODES.S_V2 &&
916916
activity.checkSelfPermission("android.permission.INTERNAL_SYSTEM_WINDOW")
917917
!= PackageManager.PERMISSION_GRANTED
918918
) {
919-
Log.w(TAG, "attachCover scvh: API 30 + INTERNAL_SYSTEM_WINDOW not granted; SCVH would throw at setView, disabling SCVH for this session")
919+
Log.w(TAG, "attachCover scvh: API ${Build.VERSION.SDK_INT} + INTERNAL_SYSTEM_WINDOW not granted; SCVH would throw at setView, disabling SCVH for this session")
920920
scvhDisabled = true
921921
return false
922922
}
@@ -939,43 +939,13 @@ class HybridCover : HybridCoverSpec() {
939939
try {
940940
host.setView(content, width, height)
941941
} catch (e: Throwable) {
942-
Log.w(TAG, "attachCover scvh: setView failed (${e.javaClass.simpleName}): ${e.message}; disabling SCVH for this session")
943-
scvhDisabled = true
944-
// Defer the release: a SCVH whose `setView` (or follow-up step)
945-
// partially failed often still has a window registered in its
946-
// internal `WindowlessWindowManager` plus a TraversalRunnable
947-
// queued on its ViewRootImpl. Releasing inline removes the WWM
948-
// token before that runnable fires next vsync, which throws
949-
// `IllegalArgumentException: Invalid window token` from
950-
// `Looper.loop` — the exact crash reported on cold-booted
951-
// emulators where `setView` hits `SecurityException: Requires
952-
// INTERNAL_SYSTEM_WINDOW permission`. `deferredReleaseScvh`
953-
// waits one Choreographer frame so the in-flight runnable
954-
// drains against a still-valid token. The caller falls through
955-
// to the legacy attach path immediately (this `return false`),
956-
// independent of when the deferred release actually runs.
957-
deferredReleaseScvh(host)
942+
failScvhAttach(host, "setView failed (${e.javaClass.simpleName}): ${e.message}")
958943
return false
959944
}
960945

961946
val pkg = host.surfacePackage
962947
if (pkg == null) {
963-
Log.w(TAG, "attachCover scvh: surfacePackage is null; disabling SCVH for this session")
964-
scvhDisabled = true
965-
// Defer the release: a SCVH whose `setView` (or follow-up step)
966-
// partially failed often still has a window registered in its
967-
// internal `WindowlessWindowManager` plus a TraversalRunnable
968-
// queued on its ViewRootImpl. Releasing inline removes the WWM
969-
// token before that runnable fires next vsync, which throws
970-
// `IllegalArgumentException: Invalid window token` from
971-
// `Looper.loop` — the exact crash reported on cold-booted
972-
// emulators where `setView` hits `SecurityException: Requires
973-
// INTERNAL_SYSTEM_WINDOW permission`. `deferredReleaseScvh`
974-
// waits one Choreographer frame so the in-flight runnable
975-
// drains against a still-valid token. The caller falls through
976-
// to the legacy attach path immediately (this `return false`),
977-
// independent of when the deferred release actually runs.
978-
deferredReleaseScvh(host)
948+
failScvhAttach(host, "surfacePackage is null")
979949
return false
980950
}
981951
val sc = pkg.surfaceControl
@@ -989,22 +959,7 @@ class HybridCover : HybridCoverSpec() {
989959
.setAlpha(sc, if (visible) 1f else 0f)
990960
.apply()
991961
} catch (e: Throwable) {
992-
Log.w(TAG, "attachCover scvh: initial setAlpha failed (${e.javaClass.simpleName}): ${e.message}; disabling SCVH for this session")
993-
scvhDisabled = true
994-
// Defer the release: a SCVH whose `setView` (or follow-up step)
995-
// partially failed often still has a window registered in its
996-
// internal `WindowlessWindowManager` plus a TraversalRunnable
997-
// queued on its ViewRootImpl. Releasing inline removes the WWM
998-
// token before that runnable fires next vsync, which throws
999-
// `IllegalArgumentException: Invalid window token` from
1000-
// `Looper.loop` — the exact crash reported on cold-booted
1001-
// emulators where `setView` hits `SecurityException: Requires
1002-
// INTERNAL_SYSTEM_WINDOW permission`. `deferredReleaseScvh`
1003-
// waits one Choreographer frame so the in-flight runnable
1004-
// drains against a still-valid token. The caller falls through
1005-
// to the legacy attach path immediately (this `return false`),
1006-
// independent of when the deferred release actually runs.
1007-
deferredReleaseScvh(host)
962+
failScvhAttach(host, "initial setAlpha failed (${e.javaClass.simpleName}): ${e.message}")
1008963
return false
1009964
}
1010965

@@ -1019,13 +974,7 @@ class HybridCover : HybridCoverSpec() {
1019974
try {
1020975
setChildSurfacePackage(pkg)
1021976
} catch (e: Throwable) {
1022-
Log.w(TAG, "attachCover scvh: setChildSurfacePackage failed (${e.javaClass.simpleName}): ${e.message}; disabling SCVH for this session")
1023-
scvhDisabled = true
1024-
// See the explanation on the matching `setView` recovery
1025-
// branch above — deferred release prevents the queued
1026-
// `WindowlessWindowManager.relayout` from firing on a removed
1027-
// token at the next vsync.
1028-
deferredReleaseScvh(host)
977+
failScvhAttach(host, "setChildSurfacePackage failed (${e.javaClass.simpleName}): ${e.message}")
1029978
return false
1030979
}
1031980
}
@@ -1035,22 +984,7 @@ class HybridCover : HybridCoverSpec() {
1035984
try {
1036985
activity.windowManager.addView(surfaceView, params)
1037986
} catch (e: Throwable) {
1038-
Log.w(TAG, "attachCover scvh: addView failed (${e.javaClass.simpleName}): ${e.message}; disabling SCVH for this session")
1039-
scvhDisabled = true
1040-
// Defer the release: a SCVH whose `setView` (or follow-up step)
1041-
// partially failed often still has a window registered in its
1042-
// internal `WindowlessWindowManager` plus a TraversalRunnable
1043-
// queued on its ViewRootImpl. Releasing inline removes the WWM
1044-
// token before that runnable fires next vsync, which throws
1045-
// `IllegalArgumentException: Invalid window token` from
1046-
// `Looper.loop` — the exact crash reported on cold-booted
1047-
// emulators where `setView` hits `SecurityException: Requires
1048-
// INTERNAL_SYSTEM_WINDOW permission`. `deferredReleaseScvh`
1049-
// waits one Choreographer frame so the in-flight runnable
1050-
// drains against a still-valid token. The caller falls through
1051-
// to the legacy attach path immediately (this `return false`),
1052-
// independent of when the deferred release actually runs.
1053-
deferredReleaseScvh(host)
987+
failScvhAttach(host, "addView failed (${e.javaClass.simpleName}): ${e.message}")
1054988
return false
1055989
}
1056990

@@ -1718,6 +1652,67 @@ class HybridCover : HybridCoverSpec() {
17181652
/// old SCVH's deferred release is independent and runs normally.
17191653
/// - Choreographer unavailable: requires a Looper on the calling
17201654
/// thread. `detachCoverView` runs on main, which always has one.
1655+
/// SCVH attach-recovery helper. Run when any step of
1656+
/// `tryAttachCoverViaScvh` after the SCVH ctor throws or returns an
1657+
/// invalid result (`setView`, null `surfacePackage`, initial
1658+
/// `setAlpha`, `setChildSurfacePackage`, `addView`). Performs the
1659+
/// three things every recovery branch must do:
1660+
///
1661+
/// 1. Latches `scvhDisabled = true` so subsequent attaches in this
1662+
/// process skip SCVH entirely. Failure modes have been
1663+
/// consistent per device — if it failed once it will keep
1664+
/// failing.
1665+
/// 2. **SYNCHRONOUSLY** calls `unscheduleScvhTraversals(host)`.
1666+
/// `ViewRootImpl.setView` queues a `TraversalRunnable` via
1667+
/// `requestLayout()` BEFORE the throwing `addToDisplay`, so by
1668+
/// the time the SCVH catch block runs there is already a
1669+
/// runnable in the SCVH's internal Choreographer queue. That
1670+
/// runnable fires at the next vsync in the TRAVERSAL phase and
1671+
/// calls `WindowlessWindowManager.relayout` against a token
1672+
/// that was never registered with the WWM, throwing
1673+
/// `IllegalArgumentException: Invalid window token (never
1674+
/// added or removed already)` from `Looper.loop`. The
1675+
/// `deferredReleaseScvh` below CANNOT prevent this — it posts
1676+
/// via `Choreographer.postFrameCallback` (animation phase) →
1677+
/// `mainHandler.post`, which always runs strictly AFTER the
1678+
/// traversal phase of the same vsync. Calling the reflective
1679+
/// unschedule synchronously HERE — main-thread, before
1680+
/// returning to the caller — is what actually drains the
1681+
/// queued runnable before the next vsync fires. The unschedule
1682+
/// latches off (`scvhReflectionDisabled = true`) on reflection
1683+
/// failure, so this is a no-op on devices where the hidden-API
1684+
/// probe isn't available; on those devices the
1685+
/// `INTERNAL_SYSTEM_WINDOW` pre-check at the top of
1686+
/// `tryAttachCoverViaScvh` is the primary line of defence and
1687+
/// this helper is unreachable in the steady state.
1688+
/// 3. Defers `host.release()` across one Choreographer frame via
1689+
/// `deferredReleaseScvh`. By the time the release fires the
1690+
/// queued runnable has been cancelled and the SCVH ViewRootImpl
1691+
/// has had a chance to settle; release runs against a stable
1692+
/// tree.
1693+
///
1694+
/// The matching crash was originally reported on Android 11 (API 30,
1695+
/// Galaxy A05 / SM-A057F) and reproduced 1:1 on a Pixel 6 / Android
1696+
/// 12 (API 31) emulator with this exact stack:
1697+
///
1698+
/// FATAL EXCEPTION: main
1699+
/// java.lang.IllegalArgumentException: Invalid window token
1700+
/// at WindowlessWindowManager.relayout
1701+
/// at ViewRootImpl.relayoutWindow
1702+
/// at ViewRootImpl.performTraversals
1703+
/// at ViewRootImpl.doTraversal
1704+
/// at ViewRootImpl$TraversalRunnable.run
1705+
/// at Choreographer$CallbackRecord.run
1706+
/// at Choreographer.doCallbacks
1707+
/// at Choreographer.doFrame
1708+
/// at Looper.loop
1709+
private fun failScvhAttach(host: SurfaceControlViewHost, reason: String) {
1710+
Log.w(TAG, "attachCover scvh: $reason; disabling SCVH for this session")
1711+
scvhDisabled = true
1712+
unscheduleScvhTraversals(host)
1713+
deferredReleaseScvh(host)
1714+
}
1715+
17211716
private fun deferredReleaseScvh(host: SurfaceControlViewHost) {
17221717
try {
17231718
Choreographer.getInstance().postFrameCallback {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-native-cover",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"description": "🔒 Native privacy cover for React Native. Hides your app behind an overlay in the iOS App Switcher and Android Recents screen.",
55
"main": "./lib/commonjs/index.js",
66
"module": "./lib/module/index.js",

0 commit comments

Comments
 (0)