Skip to content

fix(android): improve home press reliability#2

Merged
amillez merged 1 commit into
mainfrom
fix/improve-android-home-reliability
May 13, 2026
Merged

fix(android): improve home press reliability#2
amillez merged 1 commit into
mainfrom
fix/improve-android-home-reliability

Conversation

@amillez
Copy link
Copy Markdown
Owner

@amillez amillez commented May 13, 2026

Summary

Fix the Android race where the privacy cover would sometimes miss the Recents thumbnail, the underlying app content leaked through intermittently, especially under load or with a <Modal> open. Replaces the racy view.alpha toggle with a SurfaceControlViewHostbacked cover that toggles visibility via a thread-safe SurfaceControl.Transaction directly from the broadcast HandlerThread, and proactively re-parents the cover when a Modal Dialog opens so the very first Home press behind a modal also wins.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing behavior to change)
  • Documentation only
  • Build / CI / tooling

Scope

  • iOS implementation (ios/HybridCover.swift and friends)
  • Android implementation (android/src/main/java/com/margelo/nitro/cover/HybridCover.kt and friends)
  • TypeScript spec (src/specs/cover.nitro.ts) — regenerate with npm run specs
  • Example app (example/)
  • Maestro flows (.maestro/flows/)
  • Documentation (README.md)

Checklist

  • I ran npm run typecheck and it passes
  • I ran npm run specs after editing any *.nitro.ts file and committed the regenerated nitrogen/generated/ files (no spec changes)
  • I added or updated Maestro flows for any user-visible behavior change (behavior of public API unchanged; only internal mounting strategy)
  • I tested the change in the example app on Android (Pixel 9 emulator, API 37; color, image, and blur modes; with and without <Modal> open; 10+ background cycles per mode)
  • I updated the README if the public API or example app changed (no public API change)

How to test

cd example
npm install
npm run prebuild     # only if pods/gradle are out of sync
npm run android

Then in the example app:

  1. Tap Enable cover, pick a distinctive color (e.g. Indigo).
  2. Press Home, open Recents — the thumbnail should show a solid indigo card (not the underlying app content). Repeat 10+ times — should be 100% reliable.
  3. Switch to Add icon as overlay (image mode) — same test, Recents should show indigo + the icon overlay.
  4. Switch to Blur → Dark / 0.4 — Recents should show a clearly blurred app surface (text unreadable), not the sharp UI.
  5. Scroll down, tap Open modal, then press Home on the very first try — Recents should show the cover, not the modal content. Repeat with Close modal → Open modal → Home to confirm the re-open path works too.

Optional diagnostics (recommended on slower devices to confirm the fast path engaged):

adb logcat -c
adb logcat -s Cover:I Cover:W -v time

Expected lines on a clean run:

  • attachCover scvh: attached size=…x… visible=false sc=ok — SCVH path engaged on API 30+
  • broadcast: fast scvh=true refl=false dt=0-2msSurfaceControl.Transaction fired from the broadcast HandlerThread before main even sees the event
  • ensureCoverOnTopmost: reparent (topmost=DecorView) — fires when a Modal Dialog opens and we proactively re-parent the cover

Related issues

Closes #1


What was wrong (for reviewers / future readers)

The cover was being mounted from ACTION_CLOSE_SYSTEM_DIALOGS (home/recents/assist broadcast) via view.alpha = 1 on a pre-mounted FrameLayout. That toggle has to:

  1. Invalidate the view → wait for next vsync,
  2. Run ViewRootImpl traversal → render → buffer queued to SurfaceFlinger,
  3. Wait for the next vsync compose to actually present the alpha=1 buffer.

That's ~2 vsyncs (≈33 ms at 60 Hz) of latency, and on some devices the OS captures the Recents thumbnail within a single vsync of the user-leave signal (we measured 7–18 ms between broadcast and onPause on the affected device). The toggle finishes too late, the snapshot grabs the alpha=0 frame, the cover is "missing".

Compounding it: when a React Native <Modal> was open, the cover (a sub-window of activity main) was z-ordered below the modal at compose time, so even on devices where the alpha toggle did land in time, the modal would occlude it on the first leave. The reactive re-attach inside addCover was correct logic, but landed after the snapshot was already taken.

What the fix does

1. SurfaceControlViewHost + direct SurfaceControl.Transaction

On API 30+, the cover content's FrameLayout (color / image / blur — unchanged rendering logic, RenderEffect blur still works) is hosted inside a SurfaceControlViewHost that we own. The cover Window's root is now a SurfaceView that reparents the SCVH's SurfacePackage. The SCVH's SurfaceControl is captured at attach time.

When the leave broadcast fires, the receiver, running on its own HandlerThread, does:

SurfaceControl.Transaction()
  .setAlpha(scvhSurfaceControl, 1f)
  .apply()

apply() is thread-safe, applied atomically at the next SurfaceFlinger compose. No main-thread hop, no ViewRootImpl traversal, no buffer re-render. The buffer was rendered once at attach when enable() ran, with view.alpha = 1 pinned. Visibility is now a pure SurfaceFlinger-level alpha multiplier on a pre-rendered layer. The pipeline shrinks from ~33 ms to ~16 ms (one compose vsync), which fits inside the observed snapshot window.

(Animated show() / hide() use a ValueAnimator + per-frame Transaction.setAlpha; ~9 binders over a 150 ms fade.)

The older view.alpha path is kept as a fallback for API <30 and for the case where SCVH creation fails. Strictly a fast-path addition, no regression.

2. Blur capture must skip the cover's own SurfaceView

CoverBlurRenderer.render calls CoverWindowAttachment.topmostHostViewFor(activity, exclude = target.rootView) to pick the source view to capture-and-blur. With SCVH, target.rootView is the SCVH-internal FrameLayout which is not in WindowManagerGlobal.mViews, but our wrapping SurfaceView IS. The exclude no longer matched anything from the cover, the top-most view found was our own SurfaceView, and software-drawing a SurfaceView produces a transparent bitmap (its content lives in a separate hardware surface). Result: the blur ImageView got an empty source → RenderEffect blurred nothing → only the tint showed up → app content was readable straight through the "blur".

Fix: topmostHostViewFor now accepts a second exclude2 parameter, CoverBlurRenderer.render takes an alsoExclude: View?, and refreshBlurIfActive passes coverView (the SurfaceView on SCVH path; same as target.rootView on legacy path, harmless duplicate).

3. Proactive modal-token reparenting

The pre-mounted cover lives on the activity's main window token. When a <Modal> opens, RN creates a separate top-level Dialog window with its own token; the cover (a sub-window of activity main) ends up z-ordered below the modal in the SurfaceFlinger composition. The first leave broadcast fires while the cover is still on the wrong token, alpha=1 is applied correctly, but the modal layer occludes it. By the time addCover runs on main and re-attaches the cover to the modal's token, the snapshot is already captured.

Fix: install a ViewTreeObserver.OnWindowFocusChangeListener on the activity's decor view at pre-mount time. Whenever focus flips on the activity window (which happens when a Modal opens, closes, or any other top-level view in our process steals focus), call ensureCoverOnTopmost():

private fun ensureCoverOnTopmost() {
  if (!isEnabled || isVisible) return
  val topmost = CoverWindowAttachment.topmostHostViewFor(activity, exclude = coverView) ?: decor
  val targetToken = topmost.windowToken ?: return
  if (coverAttachedToken === targetToken) return   // already on the correct token
  attachCover(activity, targetToken = targetToken, visible = false, animated = false)
}

This pre-reparents the (still invisible) cover onto whichever window is currently on top. By the time the user actually backgrounds, the cover is already above the modal. The broadcast's fast SCVH alpha-toggle lands on a layer that's already in front of everything. First home-press with a modal open now works.

Filter-safe: topmostHostViewFor only reads WindowManagerGlobal.mViews (our process's view list). System dialogs from other processes (permission prompts, notification shade) don't show up there, so they don't trigger a re-attach.

Smaller helpers also in this change

  • pendingUserLeaveMount flag + synchronous mount inside onActivityPaused — closes the secondary race where the broadcast's postAtFrontOfQueue is stuck behind a long-running main-thread message.
  • SurfaceControlAccess reflection helper best-effort attempt to grab the legacy ViewRootImpl.mSurfaceControl so the same fast-path technique can engage on API 29 (no SCVH) and on devices that allow the reflection. No-ops gracefully on devices with strict hidden-API enforcement.
  • params.preferredRefreshRate = display.supportedModes.max(refreshRate) on the cover window — asks the OS to drive the display at its highest rate while the cover is attached, shrinking vsync interval where supported.
  • Comprehensive Log.i/Log.w markers tagged Cover at every entry point on the leave path, with SystemClock.uptimeMillis() timestamps — makes future device-specific regressions diagnosable from a single adb logcat -s Cover:I Cover:W.

@amillez amillez merged commit d51398e into main May 13, 2026
3 checks passed
@amillez amillez deleted the fix/improve-android-home-reliability branch May 13, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Android sometimes doesn't show the cover when pressing home

1 participant