Skip to content

Screen capture

Keep a screen that shows sensitive or paid content out of screenshots and recordings, and find out when a screenshot is taken. @symbiote-native/screen-capture wraps expo-screen-capture so every SymbioteNative adapter can reach it, not just React. This matters most on Android, where the media-projection API lets other apps capture or share the screen even from the background.

The plain async functions are shared by every adapter. The lifecycle bindings (usePreventScreenCapture, useScreenshotListener, usePermissions) are ported to all five, in each framework’s own idiom.

OS platform Support
iOS live
Android live
Framework adapter Support
React live
Vue live
Angular live
Svelte live
Solid live
Terminal window
npm install @symbiote-native/screen-capture

Scaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --screen-capture (or add --screen-capture in an existing app) installs and wires this for you - see @symbiote-native/cli.

expo-screen-capture and expo-modules-core come along as regular dependencies, pinned to exact versions. Never install either yourself, and never add the expo meta-package to your project (it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).

Prevent capture for as long as a screen is mounted, and react to screenshots:

import { useScreenshotListener, usePreventScreenCapture } from '@symbiote-native/screen-capture/react';
export default function SecretScreen() {
usePreventScreenCapture();
useScreenshotListener(() => console.log('screenshot taken'));
return <text>Sensitive content</text>;
}
import {
addScreenshotListener,
allowScreenCaptureAsync,
preventScreenCaptureAsync,
} from '@symbiote-native/screen-capture';
await preventScreenCaptureAsync();
const subscription = addScreenshotListener(() => console.log('screenshot taken'));
// later:
subscription.remove();
await allowScreenCaptureAsync();

On the Android emulator, run adb shell input keyevent 120 in a separate terminal to trigger a screenshot. In the iOS Simulator, use Device > Trigger Screenshot in the menu bar.

Signature Description
isAvailableAsync(): Promise<boolean> Whether the native module implements the prevent and allow pair
preventScreenCaptureAsync(key?): Promise<void> Blocks screenshots and recordings until a matching allowScreenCaptureAsync. Defaults the key to 'default'
allowScreenCaptureAsync(key?): Promise<void> Releases one key. Capture is allowed again once every active key is released
enableAppSwitcherProtectionAsync(blurIntensity?): Promise<void> Blurs the app in the iOS app switcher. Throws UnavailabilityError on Android
disableAppSwitcherProtectionAsync(): Promise<void> Removes the blur. Throws UnavailabilityError on Android
addScreenshotListener(listener): EventSubscription Calls listener when the user takes a screenshot while the app is in the foreground
removeScreenshotListener(subscription): void Deprecated upstream. Call subscription.remove() instead
getPermissionsAsync(): Promise<PermissionResponse> Reads the media permission. Android-only concept; always granted on iOS
requestPermissionsAsync(): Promise<PermissionResponse> Prompts for it. Android-only concept; always granted on iOS
Adapter Prevent while mounted Listen for screenshots Permissions
React usePreventScreenCapture(key?) useScreenshotListener(listener) usePermissions()
Vue usePreventScreenCapture(key?) useScreenshotListener(listener) usePermissions()
Svelte usePreventScreenCapture(key?) useScreenshotListener(listener) usePermissions()
Solid createPreventScreenCapture(key?) createScreenshotListener(listener) createPermissions()
Angular PreventScreenCaptureService.connect(key?) ScreenshotListenerService.connect(listener) PermissionsService

React’s usePermissions() returns [status, requestPermission, getPermission, error]; the failure of the mount-time fetch lands in the fourth slot.

  • Prevent and allow are counted by key. Two screens can each call preventScreenCaptureAsync with their own key; capture is allowed again only after both release. Use a distinct key per caller so one screen leaving does not unblock another.
  • The app switcher blur is iOS only. It hides the app preview when the user opens the app switcher; both calls throw UnavailabilityError on Android.
  • Screenshot detection needs the app in the foreground. The listener does not fire for screenshots taken while the app is backgrounded.
  • It can only be verified on a device or simulator. The headless tests fake the native module, so they prove key counting, the app-switcher failure on Android and the permission fallback on iOS.

What does a protected screen look like in a screenshot? On Android the capture is blocked or black. On iOS the protected content is hidden behind a blank layer rather than your UI. Test on a device to see exactly what your users and screen recorders get.

usePreventScreenCapture does nothing in my test. Make sure the call runs on mount and the component is on screen, and that you test on a real device or emulator, not only in the headless tests. A different key per caller keeps one screen’s cleanup from unblocking another.

The screenshot callback never fires. It fires only while the app is in the foreground. On Android 13 and lower it also needs READ_MEDIA_IMAGES; on Android 14 and later no permission is needed.

How do I trigger a screenshot in an emulator? Android: adb shell input keyevent 120. iOS Simulator: Device > Trigger Screenshot.

Does it hide the app preview in the app switcher? Not by itself. On iOS call enableAppSwitcherProtectionAsync() for that.

Sources: Expo docs: ScreenCapture, expo/expo#37874 implement screenshot prevention on iOS, expo/expo#21416 docs wrongly say usePreventScreenCapture is not possible on iOS.

expo-screen-capture’s JS is hand-ported into this package’s core/, resolving ExpoScreenCapture through expo-modules-core rather than the expo meta-package. The async functions and a shared permissions runtime live once in core/; each adapter supplies only its own lifecycle primitive (effect, composable, rune, onCleanup, injectable service). The native code is never vendored: expo-modules-autolinking resolves it from node_modules (see the native setup guide).