Native privacy cover for React Native. Hides your app behind an opaque view in the iOS App Switcher and Android Recents screen.
Built with Nitro Modules.
- Native lifecycle hooks β mounts the cover synchronously from
applicationDidEnterBackground(iOS) andACTION_CLOSE_SYSTEM_DIALOGS(Android), before the OS captures the App Switcher / Recents thumbnail. - Four content modes β solid color, image, image-on-color, or blur. Modes are mutually exclusive and switch in place.
- Configurable image overlay β
resizeMode, anchor (x/y), and explicitwidth/heightfor badge-style placement. Decoded off the main thread; bitmap is held so re-mounts paint instantly. - Image source flexibility β
file://,http(s)://,data:URLs, and React Native bundled assets viaImage.resolveAssetSource. - Tunable blur β
light/dark/regular/extraLightstyles with a[0, 1]intensity that updates in place. - Fade animation β configurable duration and easing for foreground unmount and manual
show()/hide(); skipped on background mount where there is no time before the OS snapshot. - Above-modal rendering β paints above React Native
<Modal>on both platforms (dedicatedUIWindowabove.alerton iOS;TYPE_APPLICATION_PANELwindow on Android). - Multi-scene support β every attached scene gets its own cover window (iPad split view, Stage Manager, visionOS).
- New and Old Architecture β both supported via
react-native-nitro-modules.
Hiding sensitive content from the App Switcher / Recents thumbnail cannot be done from JavaScript reliably:
- iOS captures the App Switcher snapshot shortly after
applicationDidEnterBackground:returns. By then the React Native JS thread has been suspended β anyAppState.addEventListener('change', β¦)callback that tries to mount an overlay view is racing the snapshot and almost always loses. AppState === 'inactive'also fires for system permission dialogs, Face ID / Touch ID prompts, Control Center, and the Apple Pay sheet. So you can't just react toinactiveeither β your "privacy cover" would flash in the middle of a real authentication flow.- Android captures the Recents thumbnail shortly after
Activity.onPause, while the JS bridge is winding down β same shape of problem. Reacting toonPausedirectly is also wrong, for the same reasoninactiveis wrong on iOS: it fires for permission dialogs and biometric prompts.
The only correct approach is to install the cover view natively, synchronously, from applicationDidEnterBackground (iOS) and the ACTION_CLOSE_SYSTEM_DIALOGS system broadcast on Android β both of which fire only for real backgrounding, before the OS captures the thumbnail. That is exactly what this module does.
npm install react-native-cover react-native-nitro-modulesCocoaPods (iOS):
cd ios && pod installFor Expo apps, run a prebuild after installing:
npx expo prebuild --cleanThe module ships a react-native-nitro-modules HybridObject. Both the New Architecture and the Old Architecture are supported.
import { Image } from "react-native";
import { Cover } from "react-native-cover";
// Arm the cover. Once enabled, the cover is mounted natively when the
// app goes to background and removed when it comes back to foreground.
Cover.enable();
// Background color β opaque black by default. Use #00000000 for a
// transparent background under an image-only cover. Accepts #RGB,
// #RRGGBB, and #RRGGBBAA.
Cover.setColor("#1E1B4B");
// Image overlay (rendered on top of the background color). All fields
// except `uri` are optional and default to filling the cover with
// `contain` / `center` / `center`. `width` and `height` are in DIPs
// (points); 0 (or omitted) means "fill the cover on that axis". The
// image is decoded off the main thread and the bitmap is held so
// subsequent background-mounts paint instantly.
const asset = Image.resolveAssetSource(require("./privacy-logo.png"));
Cover.setImage({ uri: asset.uri, resizeMode: "contain", y: "bottom" });
// Blur β replaces color + image visually. `setBlur` clears the color
// (back to opaque black) and the image overlay; switching back via
// `setColor` / `setImage` starts from a clean slate. `intensity`
// defaults to 0.4 (soft frosted glass); 1.0 is the full UIBlurEffect /
// max GPU blur radius. Re-issuing `setBlur` with the same `style` but a
// different `intensity` updates in place β fast and animatable.
Cover.setBlur("dark");
Cover.setBlur("dark", 1.0);
// Remove the image overlay (and exit blur mode). The background color
// is preserved.
Cover.clearImage();
// Fade animation for foreground unmount and manual show/hide. Skipped
// on the background-mount path because the OS snapshots the App Switcher
// before any animation could finish. `easing` defaults to `easeInOut`.
Cover.setFade(200);
Cover.setFade(600, "easeIn");
Cover.disable();
Cover.isEnabled;
Cover.isVisible;
Cover.show();
Cover.hide();The cover has four visual combinations:
| Combination | How to get it |
|---|---|
| Color only | setColor(hex) β image cleared (or never set) |
| Image only | setColor("#00000000") + setImage({ uri }) β transparent background under the image |
| Image + color | setColor(hex) + setImage({ uri, β¦ }) β color paints behind, image positioned on top |
| Blur | setBlur(style) β replaces color + image visually and clears their state |
Defaults until any setter is called: opaque black, no image, no blur.
The mode setters are mutually exclusive β each one resets the state owned by the others:
setColor,setImage, andclearImageexit blur mode.setBlurresets the background color to opaque black and clears the image overlay.
So a setBlur followed later by setColor returns to a flat color cover with no image β there is no implicit "restore the pre-blur cover" behavior.
Setters are intended to be called rarely (typically once). They dispatch all UI work to the main thread asynchronously, so they never block the JS thread, but they are not optimized for per-render or per-frame use.
| Param | Type / values | Notes |
|---|---|---|
style |
'light' | 'dark' | 'regular' | 'extraLight' |
iOS maps directly to UIBlurEffect.Style. |
intensity |
number in [0, 1], default 0.4 |
iOS uses a UIViewPropertyAnimator paused at fractionComplete = intensity so any value between 0 and 1 is allowed without dipping into private API. Android scales the RenderEffect blur radius linearly (max 50 px); 0 removes the blur effect entirely on both platforms. |
Calling setBlur repeatedly with the same style but a different intensity is fast β the existing visual-effect view is updated in place rather than rebuilt.
Cover.setImage({
uri: "file:///β¦/logo.png",
resizeMode: "contain", // optional, default 'contain'
x: "center", // optional, default 'center'
y: "bottom", // optional, default 'center'
width: 120, // optional, default 0 = fill cover width
height: 120, // optional, default 0 = fill cover height
});| Field | Type / values | Default | Notes |
|---|---|---|---|
uri |
string (required) |
β | file://, http(s)://, data:, RN bundled-asset URI. See "Image source". |
resizeMode |
'cover' | 'contain' | 'stretch' | 'center' |
'contain' |
Maps to UIKit contentMode and Android ImageView.ScaleType. |
x |
'left' | 'center' | 'right' |
'center' |
Horizontal anchor of the image inside its sized box. |
y |
'top' | 'center' | 'bottom' |
'center' |
Vertical anchor of the image inside its sized box. |
width |
number (points / DIPs) |
0 |
0 = full cover width. Otherwise the image is laid out into a fixed column. |
height |
number (points / DIPs) |
0 |
0 = full cover height. Otherwise the image is laid out into a fixed row. |
For stretch and cover the image fills both dimensions of its box, so x / y have no visible effect. For contain and center the anchor controls where the image sits inside the available space. Combined with non-zero width / height you get badge-style placement (e.g. a 120Γ120 logo anchored to the bottom-right corner of the cover).
import { Image } from "react-native";
const asset = Image.resolveAssetSource(require("./privacy-logo.png"));
Cover.setImage({ uri: asset.uri });The native side decodes:
file://and bare paths viaUIImage(contentsOfFile:)/BitmapFactory.decodeFilehttp://andhttps://viaURLSession(iOS) /HttpURLConnection(Android), each with a 10-second timeoutdata:URLs, both base64 and percent-encoded- On Android, also
file:///android_asset/<path>and bare resource names fromImage.resolveAssetSource(require(...))in release builds (resolved viaAssetManager/Resources)
The decoded bitmap is held alongside the current image config. Calling setImage again with a different URI starts a fresh decode (and cancels any in-flight HTTP fetch from the previous URI); passing the same URI reuses the existing bitmap and only updates the layout fields.
setFade(durationMs, easing?) configures the fade for all subsequent
mounts and unmounts β both the lifecycle-driven ones and manual show()
/ hide(). The fade is skipped on background mount because there
is no time before the OS captures the App Switcher snapshot β the cover
needs to be opaque immediately. Fade applies to the foreground unmount
and to manual show/hide.
easing defaults to easeInOut. Default duration: 0ms (no animation).
import type { CoverModule } from "react-native-cover";- On
enable(), registers two observers onNotificationCenter.default:UIApplication.didEnterBackgroundNotificationβ for every attached scene, creates a dedicated cover-onlyUIWindowabove the alert level and mounts aUIViewcontainer in its rootViewControllerUIApplication.willEnterForegroundNotificationβ fades the containers out, hides each cover window, and detaches it from its scene so the window deallocates
- The cover lives in its own window (rather than as a subview of the host's
UIWindow) at awindowLevelabove.alertso it sits unconditionally on top of every other window in the scene β host content, RN<Modal>, status bar, and system alerts. The cover only attaches inapplicationDidEnterBackground, which does not fire while system permission dialogs / Face ID / Apple Pay are presented (those driveapplicationWillResignActiveinstead), so the high level does not interfere with those flows in normal use. - The cover window is reused across cycles (created once on
enable()and toggled viaisHidden) so the App Switcher snapshot finds an already-laid-out surface, and intermediatesetColor/setImage/setBlurcalls only mutate the existing view tree. - The view tree is rebuilt from the current
(color, image?, blur?)state whenever the mode changes:blurset β container hosts aUIVisualEffectViewfilling its bounds; color/image ignored visually.- otherwise β container is painted with
backgroundColor; if an image is set, aUIImageViewis added with a frame computed fromwidth/heightand anchored perx/y.
- Images are decoded off the main thread and the bitmap is held alongside the current image config, so the cover re-mounts synchronously without waiting for I/O. In-flight HTTP fetches are cancelled when
setImageis called again with a different URI. - All JS-thread setters dispatch the actual UI mutation to the main thread asynchronously, so the JS thread is never blocked on the run loop.
- Multi-scene (iPad split view, Stage Manager, visionOS) is supported: every attached scene gets its own cover window.
- On
enable(), registers anApplication.ActivityLifecycleCallbacksand a runtimeBroadcastReceiverforIntent.ACTION_CLOSE_SYSTEM_DIALOGS:- Broadcast received with
reason β { "homekey", "recentapps", "assist" }β mounts aFrameLayoutcontainer as its own window viaWindowManager.addViewwithTYPE_APPLICATION_PANEL onActivityStopped(backup signal for devices with flaky broadcast delivery) β mounts the cover too- The topmost-host window's focus regain (observed via
ViewTreeObserver.OnWindowFocusChangeListener) β fade the container out and detach
- Broadcast received with
- The cover is added as a separate window (rather than as a child of the activity's
decorView) so it sits above any React NativeModalβ RN Modals are Dialogs at z-layer 2, whileTYPE_APPLICATION_PANELis at z-layer 1000. The window isFLAG_NOT_FOCUSABLEso it doesn't steal input focus from a Modal underneath; it still consumes touches via the cover's ownOnTouchListener. - The cover is pre-mounted invisible (alpha=0 +
FLAG_NOT_TOUCHABLE) the moment it's enabled. The home/recents broadcast then becomes a fast property toggle β noaddView, no first-frame race against the OS thumbnail capture. - The container is rebuilt from the current
(color, image?, blur?)state every time it mounts:blurset β anImageViewfilled with a 1/4-scale snapshot of the topmost host window (so a foreground Modal's content is also obscured), blurred viaRenderEffect.createBlurEffect(API 31+) or degraded to a tinted translucent background on older Android.- otherwise β container's background is the color; if an image is set, an
ImageViewis added as a child with aLayoutParams(w, h, gravity)computed fromwidth/height(DIP-converted) andx/y.
- Images are decoded off the main thread by a single-thread executor and the bitmap is held alongside the current image config; in-flight HTTP fetches are cancelled when
setImageis called again with a different URI. - All JS-thread setters dispatch through the main
Handler, so the JS thread is never blocked. - The container's
tagandcontentDescriptionare both"CoverOverlay"/"Privacy cover"so it can be located by accessibility tooling and e2e tests.
Both fire for every loss of focus, not just real backgrounding: permission dialogs, biometric prompts, incoming calls, system control panels, the notification shade. Reacting to them would cause the cover to flash on top of system UI and break real authentication flows.
iOS gives us a clean discriminator out of the box: applicationDidEnterBackground fires only when the app actually leaves the foreground.
Android does not have a direct equivalent in Application.ActivityLifecycleCallbacks β the closest, onActivityStopped, runs too late (the OS captures the Recents thumbnail before it). What it does have is the protected system broadcast ACTION_CLOSE_SYSTEM_DIALOGS, which PhoneWindowManager dispatches just before pausing the foreground activity for a user-initiated app leave (Home key, Recents key, Assist key, or the equivalent gestures), and not for transient focus loss. We listen to that broadcast and mount the cover synchronously when the reason extra indicates a real leave.
- The
reasonextras onACTION_CLOSE_SYSTEM_DIALOGScome fromPhoneWindowManagerand have been stable across AOSP releases, but they are not part of the public Android SDK contract. An OEM that ships a custom variant ofPhoneWindowManagercould in principle omit or rename them. We have not seen this in practice on shipping devices. - Topmost-window discovery (used to attach the cover above an open
<Modal>Dialog and to capture the right surface for blur) reads the per-processWindowManagerGlobal.mViewslist via reflection. The greylist allows this on every shipping Android version we test, but it could be tightened in a future release. We fall back to the activity's own decor view if the reflection fails. - Like iOS's
didEnterBackground, this signal does not fire for non-user-initiated background transitions: incoming phone calls, foreground services taking over the screen, programmaticstartActivityto another app. The cover will not appear in the Recents thumbnail in those cases. This matches the iOS behavior β a deliberate trade-off versus the modal-flash that reacting to every focus loss would cause.
See the contributing guide to learn how to contribute to the repository and the development workflow.
MIT β see LICENSE.