detect_screenshot
A Flutter plugin for detecting screenshots and screen recordings on Android and iOS. It can block screenshots and screen recording on Android, display a customizable privacy overlay on iOS, and protect sensitive content from appearing in the app switcher on both platforms.
Features
- Detect screenshots on Android, using two approaches:
Activity.ScreenCaptureCallbackon Android 14+.- Filtered
MediaStoreobservation on Android 13 and below.
- Check if the underlying platform supports each feature.
- Put your own overlay into screenshots and recordings on iOS, without changing the live screen.
- Detect screenshots and recording on iOS and Android 15+, as a separate stream.
- Real-time events over an
EventChannel, starting and stopping with your listeners. - Report whether a device can detect at all, so a silent stream is never ambiguous.
- Block screenshots, recording and casting on Android with
FLAG_SECURE. - Hide the app in the task switcher on both platforms.
Support
| Feature | iOS | Android 15+ | Android 14 | Android 13 | Android 7–12L |
|---|---|---|---|---|---|
| Screenshot detection | ✅ | ✅ | ✅ | grant needed | grant needed |
| Screen recording detection | ✅ | ✅ | ❌ | ❌ | ❌ |
| Block capture | ❌ | ✅ | ✅ | ✅ | ✅ |
| Capture shield (overlay) | ✅ | ❌ | ❌ | ❌ | ❌ |
| App switcher privacy | ✅ | ✅ | ✅ | ✅ | ❌ |
Under the hood: userDidTakeScreenshotNotification and the scene's sceneCaptureState on iOS;
Activity.ScreenCaptureCallback (14+), WindowManager.addScreenRecordingCallback (15+), FLAG_SECURE, and
setRecentsScreenshotEnabled (13+) on Android. Below Android 14 screenshots are inferred from MediaStore,
since no screenshot API existed before then.
Install
dependencies:
detect_screenshot: ^1.0.0
Init
final ScreenshotDetector _screenshotDetector = ScreenshotDetector.instance;
Check Compatibility
Anything that works on both sits directly on the screenshotDetector main class. Anything that only exists on
one platform lives under .android or .ios, so it is obvious at the call site which is which:
await _screenshotDetector.screenshotSupport(); // both
await _screenshotDetector.android.setScreenshotBlocked(blocked: true); // Android only
await _screenshotDetector.ios.setCaptureShield(enabled: true); // iOS only
Calling one on the wrong platform is a harmless no-op, so no Platform.isIOS guards are needed. Use the
matching *Support() method when you need to know whether it will actually do something.
A quiet stream means either "nothing was captured" or "this device can't tell you". Those are very different numbers, and on Android the second is common enough to skew them.
Every feature has a matching check, all returning DetectionSupport:
switch (await _screenshotDetector.screenshotSupport()) {
case DetectionSupport.available:
break;
case DetectionSupport.permissionRequired:
await _screenshotDetector.requestMediaPermission();
case DetectionSupport.unsupported:
break;
}
| Method | Answers |
|---|---|
screenshotSupport() |
Can screenshots be detected here? |
screenRecordingSupport() |
Can screen recording be detected here? |
appSwitcherPrivacySupport() |
Can the task switcher be covered? |
android.blockingSupport() |
Does FLAG_SECURE apply here? |
ios.captureShieldSupport() |
Can captures be given an overlay? |
DetectionSupport has three values, and the middle one is the reason this exists:
available— it will workpermissionRequired— the OS can do it, the app is missing a runtime grantunsupported— the OS has no API for it, and no permission will change that
requestMediaPermission() returns true without prompting wherever the permission is irrelevant, so you don't
need to branch on platform first.
Use these to drive the UI too. A "block screenshots" toggle should be disabled when
android.blockingSupport() reports unsupported, rather than shown and silently doing nothing.
Detect
Each detector is independent and runs only while something is listening.
final _screenshotDetector = ScreenshotDetector.instance;
DetectionListener<ScreenshotEvent>? _listener;
@override
void initState() {
super.initState();
_listener = _screenshotDetector.listenScreenshots((event) {
analytics.log('screenshot', {'source': event.source.name});
});
}
@override
void dispose() {
_listener?.dispose();
super.dispose();
}
listenScreenshots(...)andlistenScreenRecording(...)return a handle withdispose().screenshotDetector.screenshotsandscreenshotDetector.screenRecordingexpose the raw streams if you wantwhere,takeand friends.- Recording reports state changes, not one-shot events. The first event describes the state at subscription time, so a listener attached mid-recording still learns about it.
screenshotDetector.isScreenRecordingActive()asks once, without subscribing.
Log the source
Every ScreenshotEvent carries the source that produced it. Coverage varies by OS version and permission
state.
Block
await _screenshotDetector.android.setScreenshotBlocked(blocked: true);
Sets FLAG_SECURE, which stops screenshots, screen recording, the recents thumbnail, and casting to
non-secure displays in one go. Enforced by the system, not by your app.
- Android only. iOS exposes nothing equivalent, and
android.blockingSupport()returnsunsupportedthere. - Whole window. You can't protect a single widget, so toggle it as routes come and go.
- Careful with platform views.
FLAG_SECUREis known to black out webviews and someSurfaceViewplayers. Test on a device before enabling it on a screen with a video player or embedded browser. android.isScreenshotBlocked()reads the window flag back, so it stays truthful if your app sets the flag itself.
Capture shield
iOS has no FLAG_SECURE. What it does have is a way to keep the app's own pixels out of a capture and leave
something else in their place — the approach messaging apps use for protected media.
final data = await rootBundle.load('assets/blocked.png');
await _screenshotDetector.ios.setCaptureShield(
enabled: true,
placeholderPng: data.buffer.asUint8List(),
);
The live screen never changes. Users see the real UI throughout; only the saved screenshot or recording contains your overlay. That is what makes it feel seamless rather than like a blackout.
- iOS only.
ios.captureShieldSupport()returnsunsupportedon Android, whereandroid.setScreenshotBlocked()is the right tool. - Covers screenshots and recordings, and needs no detection at all.
backgroundColorfills whatever the image doesn't cover. Defaults to black.
Building the overlay from a widget
captureShieldOverlay renders a widget to PNG bytes, so the overlay can be authored in Flutter with your own
fonts and theme. This is the example app's version, an asset above a line of text:
Future<Uint8List> _renderOverlay() async {
final ByteData data = await rootBundle.load('assets/blocked.png');
final ui.Codec codec = await ui.instantiateImageCodec(
data.buffer.asUint8List(),
targetWidth: 480,
);
final ui.FrameInfo frame = await codec.getNextFrame();
return captureShieldOverlay(
ColoredBox(
color: const Color(0xFF1A1A2E),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RawImage(image: frame.image, width: 180, fit: BoxFit.contain),
const SizedBox(height: 28),
const Text(
'Screenshots of this app \n is not permitted',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFFFFFFFF),
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}
await _screenshotDetector.ios.setCaptureShield(
enabled: true,
placeholderPng: await _renderOverlay(),
);
Note the RawImage. The overlay renders outside the widget tree in one synchronous pass, so anything
resolving asynchronously won't have arrived when the frame paints — an Image comes out blank. So make sure
to decode the asset up front and hand over the decoded ui.Image.
Render once and keep the bytes; re-rendering on every toggle is wasted work.
The one caveat that matters
This is the only feature here not built on a public API. Apple offers no supported way to keep content out of
a capture, so the shield relies on the internals of a secure UITextField — the app window's layer is moved
inside the private canvas that the render server omits, and a second window below shows through in its place.
A future iOS that reshapes that view hierarchy will silently stop it working. It fails open: captures
would show the real content again rather than the app breaking. Don't present it to users as a guarantee, and
check ios.captureShieldSupport() before offering it as a setting.
App switcher privacy
await _screenshotDetector.setAppSwitcherPrivacy(enabled: true);
await _screenshotDetector.setAppSwitcherPrivacy(
enabled: true,
style: AppSwitcherStyle.color,
color: Colors.black,
);
Covers the UI in the multitasking view — usually the leak that matters most, and supported on both platforms.
- iOS places a native overlay before the system takes its snapshot. That runs in UIKit deliberately: Flutter stops scheduling frames as the app backgrounds, so a widget swap loses the race.
- Android uses
setRecentsScreenshotEnabledand draws its own placeholder, sostyleandcolorare ignored. Needs Android 13+. - On Android 12L and below,
android.setScreenshotBlocked(blocked: true)also hides the recents card, at the cost of blocking screenshots entirely.
Hide content while recording
Note: this was developed as a solution before the iOS overlay override implementation. Just kept it here if someone needs it.
Blanks one part of the tree while the screen is being recorded, leaving the rest of the UI alone.
HideWhileCaptured(
replacement: const Text('Hidden while recording'),
child: CardNumber(),
)
Read the next paragraph before reaching for this. It is the narrowest tool here, and on most screens one of the other two is a better fit.
When to use it
Only two situations really call for it:
- Protecting a single widget on Android 15+.
FLAG_SECUREcovers the entire window and the capture shield is iOS-only, so this is the one way to keep a card number out of a recording while the rest of the screen records normally. - When the user should notice. The capture shield is invisible — the person recording sees the real UI and only finds the overlay afterwards. This changes the live screen, so it signals "this bit is protected" while they are recording. Different intent, occasionally the one you want.
Everywhere else, prefer android.setScreenshotBlocked() or ios.setCaptureShield().
What it cannot do
- It depends on recording detection, and inherits every weakness of it. On iOS that detection is unreliable on recent versions (see Limitations), so treat this as best-effort there rather than something to rely on.
- Android 14 and below have no recording detection at all, so the child simply stays visible.
- Screenshots are never caught. They are instantaneous; nothing reacts in time.
- There is an exposure window between a recording starting and the first blanked frame.
Options: any widget works as replacement. Left out, the child is hidden but keeps the space it occupied, so
the surrounding layout does not shift. enabled: false keeps the child visible without moving the widget in
and out of the tree.
API reference
Everything hangs off ScreenshotDetector.instance.
Streams and listeners
| Member | Returns | Notes |
|---|---|---|
screenshots |
Stream<ScreenshotEvent> |
Broadcast. One event per screenshot. |
screenRecording |
Stream<ScreenRecordingEvent> |
Broadcast. Emits on change, plus once at subscription. |
listenScreenshots(cb) |
DetectionListener<ScreenshotEvent> |
Same stream, with a dispose() handle. |
listenScreenRecording(cb) |
DetectionListener<ScreenRecordingEvent> |
Same stream, with a dispose() handle. |
DetectionListener.dispose() |
Future<void> |
Cancels. The native detector stops once nothing is listening. |
Queries
| Method | Returns | Meaning of the result |
|---|---|---|
screenshotSupport() |
Future<DetectionSupport> |
available, permissionRequired, or unsupported. |
screenRecordingSupport() |
Future<DetectionSupport> |
unsupported below Android 15. |
isScreenRecordingActive() |
Future<bool> |
Capture state right now. False where unsupported. |
requestMediaPermission() |
Future<bool> |
True if the grant is now held. True without prompting where irrelevant. |
appSwitcherPrivacySupport() |
Future<DetectionSupport> |
unsupported below Android 13. |
android.isScreenshotBlocked() |
Future<bool> |
Reads FLAG_SECURE off the window. False on iOS. |
android.blockingSupport() |
Future<DetectionSupport> |
unsupported on iOS. |
ios.captureShieldSupport() |
Future<DetectionSupport> |
unsupported on Android. |
Actions
All return Future<void> and complete once the platform has applied the change. None throw on the wrong
platform — they no-op, and the matching *Support() tells you which case you are in.
| Method | Parameters |
|---|---|
setAppSwitcherPrivacy(...) |
enabled, style (blur | color), color |
android.setScreenshotBlocked(...) |
blocked |
ios.setCaptureShield(...) |
enabled, placeholderPng, backgroundColor |
Event models
| Type | Fields |
|---|---|
ScreenshotEvent |
source (ScreenshotSource), timestampMs (int), mediaUri (String?) |
ScreenRecordingEvent |
isRecording (bool), timestampMs (int) |
mediaUri is only populated for ScreenshotSource.mediaStore. The system callbacks deliberately withhold any
reference to the captured image, so it is null on iOS and on Android 14+.
Enums
| Type | Values |
|---|---|
ScreenshotSource |
systemCallback (Android 14+), mediaStore (Android 13 and below), systemNotification (iOS) |
DetectionSupport |
available, permissionRequired, unsupported |
AppSwitcherStyle |
blur, color |
Helpers
| Member | Returns | Notes |
|---|---|---|
captureShieldOverlay(widget, {size, pixelRatio, textDirection}) |
Future<Uint8List> |
PNG bytes. Throws StateError on an empty size or a failed encode. |
HideWhileCaptured |
widget | child, replacement, enabled. |
Permissions
Declared by the plugin, nothing for you to add:
DETECT_SCREEN_CAPTUREandDETECT_SCREEN_RECORDING— install-time, no prompt, no Play Console declaration.READ_EXTERNAL_STORAGEcapped atmaxSdkVersion="32"for theMediaStorefallback. It's a runtime permission, so declaring it isn't holding it — callrequestMediaPermission().
Android 13 is left to you. That band needs READ_MEDIA_IMAGES, which falls under Google Play's Photo and
Video Permissions policy and needs a declaration naming a qualifying core use case. Screenshot analytics does
not fall under this. If your app already declares it for a real photo feature, Android 13 detection starts
working with no further change:
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
To drop the storage permission and keep only the Android 14+ path:
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove" />
iOS needs nothing.
How MediaStore events are filtered (below Android 14)
Below Android 14, ContentObserver fires for every image written to shared storage — downloads, camera
captures, cloud sync. Four rules turn that into a usable signal, each fixing a way the naive version goes
wrong:
- Directory, not file name. A row counts when it lands in a
Screenshotsdirectory. Display names get localised (Capture d'écran,スクリーンショット); directory names on disk don't, so name matching silently under-reports every non-English device. - Recency. A media rescan or a restored backup re-notifies every image on the device. Without a
DATE_ADDEDwindow that's one phantom event per stored screenshot. - Row identity. Observers fire more than once per insert. Events are keyed on row id, so one screenshot reports once while two taken back to back still report twice.
- Foreground only. Nothing is reported while your app is backgrounded, where a write to shared storage has nothing to do with your UI.
What's different in this library
- Uses the real platform callbacks where they exist, instead of applying one
MediaStorestrategy to every OS version. - Filters
MediaStoreevents rather than forwarding raw observer notifications. - Classifies by directory, so it behaves the same in every locale.
- Reports what a device can't do, instead of going quiet and letting you assume.
- Tags every event with its detection tier, so counts stay comparable.
- Covers screen recording on Android, not just iOS.
- Can use custom widget as the iOS screen recording blocker overlay.
- Puts a real overlay into iOS captures, rather than only detecting them after the fact.
- Ships no permission that carries Play review obligations.
Limitations
- The Android 14+ callback misses
adb shell screencapand some OEM assistant gestures. - On iOS, capture state is also true for AirPlay mirroring and wired capture; iOS doesn't distinguish those from the built-in recorder.
- Android has no screen recording detection before 15, and no permission changes that.
- Below Android 14, detection depends on a runtime grant your app may never have been given.
- The capture shield relies on undocumented UIKit internals and can stop working on a future iOS. It fails open, so captures would show real content rather than the app breaking.
- iOS screen recording detection is unreliable on recent versions. Both push mechanisms Apple offers were measured not firing on iOS 26.6 even though the state itself reads correctly, so the plugin also polls every 500ms while a listener is attached and emits only on change. If you care about what lands in the file rather than reacting live, the capture shield is the sturdier choice.
Example
You can find a full working example in the example/ directory of the repository.
Contribution
Feel free to fork this repository and submit pull requests. Please follow best coding practices and include tests for any new features or fixes.
License
This plugin is licensed under the MIT License. See the LICENSE file for more details.
Libraries
- detect_screenshot
- Screenshot and screen recording detection for Android and iOS.