screenshot_shield 0.2.0
screenshot_shield: ^0.2.0 copied to clipboard
Detect screenshots and screen recording, blank screen captures of a whole screen or a sensitive region, and hide your app in the app switcher.
screenshot_shield #
Protect sensitive screens in your Flutter app. screenshot_shield detects screenshots
and screen recording, blanks screen captures of a whole screen or of a single sensitive
region, and hides your app's content in the app switcher.
- Prevent capture - screenshots and screen recordings come out blank (Android, iOS, Windows), and so does the app-switcher snapshot (Android, iOS). On Android, blanking and screenshot detection exclude each other, so the guards detect by default there (details).
- Detect screenshots - get a callback, plus a PNG of the guarded screen to share instead of the blanked frame (Android, iOS).
- Detect screen recording and mirroring - a stream and a getter you can react to (Android 15+, iOS; best-effort on desktop).
- Protect one region - keep a card number or a balance out of recordings while the rest of the screen stays capturable (iOS).
- App-switcher privacy - hide the app's content when it goes to the background.
- Route-aware guards - protection turns on while a screen is visible and off when it isn't, and several guards can be active at once.
Platform support #
| Feature | Android | iOS | Windows | Linux |
|---|---|---|---|---|
Prevent capture (preventCapture) |
✅ | ✅¹ | ✅ | ❌ |
| Screenshot detection | ✅² | ✅ | ❌ | ❌ |
| Screen-recording detection | ✅ 15+ | ✅ | ⚠️³ | ⚠️³ |
Region protection (ScreenshotShieldSensitiveView) |
❌ | ✅¹ | ❌ | ❌ |
App-switcher privacy (backgroundBlur) |
✅ | ✅ | ✅ | ❌ |
| Keyboard protection | ❌ | ✅¹ | ❌ | ❌ |
¹ Relies on undocumented UIKit behaviour - verify on the iOS versions you support (see iOS). ² Android 14+ uses the system API. Android 10-13 needs a media permission granted by the host app (see Android). ³ A heuristic that looks for known recorder programs; see Windows and Linux.
Install #
flutter pub add screenshot_shield
No configuration is needed for the defaults. Read Android if you want screenshot detection on Android 10-13.
Quick start #
Provide a ScreenshotShield with ScreenshotShieldScope, register its route observer
with your app, and wrap a sensitive screen in a ScreenshotShieldRouteGuard:
final routeObserver = RouteObserver<ModalRoute<void>>();
void main() {
runApp(
ScreenshotShieldScope(
shield: ScreenshotShield(),
routeObserver: routeObserver,
child: MaterialApp(
navigatorObservers: [routeObserver],
home: const PaymentScreen(),
),
),
);
}
class PaymentScreen extends StatelessWidget {
const PaymentScreen({super.key});
@override
Widget build(BuildContext context) {
return ScreenshotShieldRouteGuard(
onScreenshotDetected: (image) {
// `image` is a PNG of the guarded screen (null if capture failed).
// Show it to the user or share it, e.g. with `share_plus`.
},
child: const Scaffold(body: Center(child: Text('Card number: 1234'))),
);
}
}
While PaymentScreen is visible, captures of it come out blank and screenshots are
reported on iOS. On Android a blanked screen cannot report screenshots, so by default the
guard reports them and does not blank; pass forcePreventCapture: true to blank the
screen there instead (see Guarding a screen). When another route
covers it, protection and listening are released.
Usage #
Guarding a screen #
ScreenshotShieldRouteGuard follows the route it lives on. For content that is not a
route (tabs, overlays, embedded views) use ScreenshotShieldGuard, which is active while
it is mounted and its active flag is true:
ScreenshotShieldGuard(
active: showBalance,
child: BalanceCard(balance: balance),
)
Both guards take the same options:
| Option | Default | Effect |
|---|---|---|
preventCapture |
true |
Blank captures while the guard is active. |
detectScreenshots |
true |
Listen for screenshots while active. |
forcePreventCapture |
false |
On Android, keep prevention even when detection is on (see below). |
captureOnScreenshot |
true |
Re-rasterize the guarded subtree into a PNG on each screenshot. |
onScreenshotDetected |
- | Called with that PNG (or null). |
Guards count their requests: with two guards on screen, protection stays on until the last one that needs it goes away.
Android: the secure window flag that blanks captures also stops screenshots from
being saved, so no screenshot event can fire while it is on. When a guard asks for both,
detection wins and prevention is dropped on Android; set forcePreventCapture: true to
keep the blanked frame instead (and accept that onScreenshotDetected will not fire).
On iOS and Windows the two work together.
Detect-and-notify mode #
To let screenshots succeed and react to them instead - a Snapchat-style "they took a screenshot" flow - turn prevention off:
ScreenshotShieldRouteGuard(
preventCapture: false,
onScreenshotDetected: (image) => notifyPeer(image),
child: const ChatScreen(),
)
Protecting one region (iOS) #
ScreenshotShieldSensitiveView keeps one part of the screen out of captures while the
rest stays capturable:
ScreenshotShieldSensitiveView(
// What a capture shows where the region is. Defaults to black.
captureColor: Colors.black,
// Only needed when the child has see-through parts (rounded corners, gaps):
// the colour behind the region. Defaults to transparent.
backdropColor: Theme.of(context).colorScheme.surface,
child: const BalanceCard(),
)
To show captures something other than a solid colour - a shape matching the content, a
message, or a blur of the content - pass a capturePlaceholder. It is sized to the region
and drawn beneath the copy, so only screenshots and recordings see it:
ScreenshotShieldSensitiveView(
backdropColor: Theme.of(context).colorScheme.surface,
// A rounded box with a lock, matching a rounded card.
capturePlaceholder: DecoratedBox(
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(16)),
child: const Center(child: Icon(Icons.lock, color: Colors.white70)),
),
child: const BalanceCard(),
)
// Or a blur of the content (captures see a blurred version of it):
capturePlaceholder: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: const ColoredBox(color: Color(0x33000000)),
),
),
Anything the placeholder leaves uncovered or translucent shows the live content to captures, so keep it opaque over sensitive content unless a blur is what you want.
protection |
Covers | Cost |
|---|---|---|
SensitiveProtection.whileRecording |
screen recordings and mirroring | none while not recording |
SensitiveProtection.whileCaptured (default) |
the above, plus the app-switcher snapshot | none while not captured |
SensitiveProtection.always |
the above, plus foreground screenshots | continuous, see below |
By default the widget is effectively not there: it lays out and paints child
unchanged, creates no platform view and rasterises nothing. It takes over only while the
screen is recorded or mirrored (ScreenshotShield.isScreenRecording) or the app is in the
background. While engaged, the subtree stays live and interactive - taps, focus and text
input reach child - and a native view nested in a capture-excluded canvas shows a copy
of it that refreshes when the subtree repaints, about 30 times a second by default
(refreshInterval; Duration.zero refreshes on every frame). A capture sees
captureColor instead. Content that repaints inside its own repaint boundary - a text
field's caret and selection, a scrolling list - does not trigger a refresh; call
ScreenshotShieldSensitiveViewController.refresh() when it changes.
iOS reports a recording slightly after it starts, and the first copy takes a frame or two
to land, so the first moments of a recording can include the region, and the region can
flash briefly on screen as it engages. Use protection: SensitiveProtection.always for
content that must never appear (the copy is then already in place when a recording
starts), at the cost below.
protection: SensitiveProtection.always keeps the region engaged permanently, which also
blanks foreground screenshots, at these costs:
- The region is a native view, so it composites above Flutter content: a Flutter overlay that covers the region (a dialog, a tooltip, a selection toolbar) draws behind it.
- Each refresh is a GPU readback, so continuously repainting content (video, large
animations) keeps the CPU busy. Cap it with
refreshInterval, or use a guard instead. - The copy is one frame behind.
Region protection stands down while whole-window prevention is active.
Why a foreground screenshot cannot be blanked per region without a copy
iOS excludes a native view's own layer from captures, and Flutter renders every widget
into one surface (PlatformViewLayer is the only composited native view). Nesting live
Flutter content in the excluded layer is therefore impossible, and an excluded layer is
omitted from the capture rather than replaced by black - so a shield that is
transparent on screen would reveal the live widget to the capture as well. The only way
to blank a region in a screenshot is to display something native in it: a rasterised
copy, which is what protection: always does.
Flutter's own SensitiveContent widget is not an alternative: it obscures the entire
screen during media projection, and only on Android 15+.
App-switcher privacy #
await ScreenshotShield().setProtection(backgroundBlur: true);
| Platform | What the app switcher shows |
|---|---|
| Android 13+ | No thumbnail of the app (setRecentsScreenshotEnabled(false)); screenshots and their detection are unaffected. |
| Android 12 and below | The content blurred (with RenderMode.texture) or covered. |
| iOS | The content blurred. The blur also appears while the app is inactive, e.g. under Control Center or a system alert. |
| Windows | The app icon instead of a live preview in the taskbar thumbnail and Alt+Tab. |
While preventCapture is on, the app-switcher snapshot is blank anyway.
Screen-recording detection #
final shield = ScreenshotShield();
shield.onScreenRecordingChanged.listen((isRecording) {
// Hide sensitive content, pause playback, ...
});
await shield.startListening();
// Or read the current state at any time:
if (shield.isScreenRecording) { /* ... */ }
The stream emits the current state when listening starts and then every change. iOS reports recording, mirroring (AirPlay) and screen sharing for the app's scene; Android 15+ reports whether the app is visible in a recording.
Keyboard protection (iOS) #
The on-screen keyboard is a window of its own, so neither whole-window protection nor a region covers it - a capture with the keyboard up shows the keys.
await ScreenshotShield().setKeyboardProtection(enabled: true);
On iOS this nests the keyboard window's content in the same capture-excluded canvas, so
captures get no keyboard pixels (undocumented UIKit behaviour: verify it on the iOS
versions you support). On Android the keyboard belongs to another app and cannot be
excluded; keep sensitive input inside your app instead (an in-app keypad is ordinary
Flutter content the guards cover), or hide the keyboard while
onScreenRecordingChanged is true.
Low-level API #
The guards are built on ScreenshotShield, which you can drive directly:
final shield = ScreenshotShield();
shield.onScreenshotDetected.listen((_) => showShareSheet());
await shield.startListening(); // calls are counted
await shield.setProtection(preventCapture: true); // sets the window state directly
// Later:
await shield.setProtection(preventCapture: false);
await shield.stopListening();
setProtection sets the window's state directly, so prefer the guards when more than
one part of the app needs protection.
Platform notes #
Android #
| Android | Screenshot detection | Permission |
|---|---|---|
| 14+ (API 34) | System ScreenCaptureCallback, immediate |
DETECT_SCREEN_CAPTURE (normal, declared by the plugin) |
| 10-13 (API 29-33) | Media store observer, shortly after the image is saved | READ_EXTERNAL_STORAGE (10-12) or READ_MEDIA_IMAGES (13) - declare (see below) and request it in your app |
| 7-9 (API 24-28) | Media store observer | READ_EXTERNAL_STORAGE - declared by the plugin, request it at runtime |
Without the media permission on Android 10-13, the screenshot (owned by System UI) is
invisible to your app and no event fires. Only request it if detection on those versions
matters to you: Google Play asks apps to justify photo permissions. The plugin declares
READ_EXTERNAL_STORAGE only up to Android 9, and the manifest merger applies that limit to
your declaration too, so override it explicitly:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" tools:replace="android:maxSdkVersion" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
</manifest>
On Android 14+ the system shows a notice when an app detects a screenshot. Screenshots
taken through ADB are not reported. Screen-recording detection needs Android 15 (API 35,
DETECT_SCREEN_RECORDING).
The plugin's manifest merges DETECT_SCREEN_CAPTURE, DETECT_SCREEN_RECORDING and
READ_EXTERNAL_STORAGE (up to API 28) into your app. To drop one, add it to your app's
AndroidManifest.xml with tools:node="remove" (declare
xmlns:tools="http://schemas.android.com/tools" on the <manifest> element):
<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" tools:node="remove" />
Debug logs are off by default; enable them with
adb shell setprop log.tag.ScreenshotShield DEBUG.
iOS #
iOS has no public API for blocking screenshots. preventCapture relies on a
UITextField with isSecureTextEntry: UIKit renders such a field through a private
capture-excluded canvas layer, and the plugin nests the app's content layer inside it, so
screenshots, recordings and the app-switcher snapshot come out blank while
onScreenshotDetected still fires.
- Protection is re-applied automatically when UIKit rebuilds the view hierarchy (for example after a full-screen modal is dismissed) and when the app becomes active.
- Native modals and alerts presented over the app are separate views and are not covered.
- Because it depends on undocumented behaviour, a future iOS release can break it, and it may draw questions in App Store review. Verify it on every iOS version you support, and keep detect-and-notify mode as a fallback.
- Test on a device or with the simulator's Device > Trigger Screenshot:
xcrun simctl io screenshotreads the framebuffer directly and is never masked. The simulator never reports a recording.
No permissions or Info.plist entries are needed. The plugin ships a privacy manifest.
Windows and Linux #
- Windows:
preventCaptureusesSetWindowDisplayAffinity: on Windows 10 2004+ the window is left out of screenshots, recordings and screen sharing; older versions show it black. Windows has no screenshot notification, soonScreenshotDetectednever fires. - Linux: there is no standard mechanism for detection or prevention; the widgets still work and the calls are accepted.
- Recording detection (both): while listening, the process list is sampled every two
seconds and
onScreenRecordingChangedistruewhile a known recorder (OBS, Bandicam, Camtasia, Kazam, Kooha,wf-recorder, ...) is running. A recorder that is open but idle counts as recording, and unlisted or sandboxed recorders (and GNOME's built-in one) are missed.
Example #
A runnable example with a toggle for every feature lives in example/:
cd example
flutter run
License #
MIT - see LICENSE.
