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.ScreenCaptureCallback on Android 14+.
    • Filtered MediaStore observation 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: ^2.0.1

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.checkScreenshotListenerSupport(); // 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 you never need a guard just to call safely. You do need one to know whether the call will do anything: Platform.isAndroid and Platform.isIOS for the single-platform features, and the check*Support() methods below for the ones that vary by OS version or permission state.

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.checkScreenshotListenerSupport()) {
  case DetectionSupport.available:
    break;
  case DetectionSupport.permissionRequired:
    await _screenshotDetector.requestMediaPermission();
  case DetectionSupport.unsupported:
    break;
}
Method Answers
checkScreenshotListenerSupport() Can screenshots be detected here?
checkScreenRecordListenSupport() Can screen recording be detected here?
checkAppSwitcherPrivacySupport() Can the task switcher be covered?

Blocking and the capture shield have no such check: each exists on exactly one platform, so Platform.isAndroid and Platform.isIOS already answer it.

DetectionSupport has three values, and the middle one is the reason this exists:

  • available — it will work
  • permissionRequired — the OS can do it, the app is missing a runtime grant
  • unsupported — 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 control whose feature reports unsupported should be disabled, 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.listenForScreenshots((event) {
    debugPrint("screenshot 'source':${event.source.name}");
  });
}

@override
void dispose() {
  _listener?.dispose();
  super.dispose();
}
  • listenForScreenshots(...) and listenForScreenRecordings(...) return a handle with dispose().
  • screenshotDetector.screenshots and screenshotDetector.screenRecording expose the raw streams if you want where, take and 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, so this is a no-op there — gate the control on Platform.isAndroid.
  • Whole window. You can't protect a single widget, so toggle it as routes come and go.
  • Careful with platform views. FLAG_SECURE is known to black out webviews and some SurfaceView players. 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 display a custom overlay thats only visible in the saved screenshots or screenrecordings.

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. A no-op on Android, where android.setScreenshotBlocked() is the right tool — gate the control using Platform.isIOS.
  • Covers screenshots and recordings, and needs no detection at all.
  • backgroundColor fills 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.

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 setRecentsScreenshotEnabled and draws its own placeholder, so style and color are 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_SECURE covers 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.
listenForScreenshots(cb) DetectionListener<ScreenshotEvent> Same stream, with a dispose() handle.
listenForScreenRecordings(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
checkScreenshotListenerSupport() Future<DetectionSupport> available, permissionRequired, or unsupported.
checkScreenRecordListenSupport() 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.
checkAppSwitcherPrivacySupport() Future<DetectionSupport> unsupported below Android 13.
android.isScreenshotBlocked() Future<bool> Reads FLAG_SECURE off the window. False on iOS.

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_CAPTURE and DETECT_SCREEN_RECORDING — install-time, no prompt, no Play Console declaration.
  • READ_EXTERNAL_STORAGE capped at maxSdkVersion="32" for the MediaStore fallback. It's a runtime permission, so declaring it isn't holding it — call requestMediaPermission().

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 Screenshots directory. 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_ADDED window 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 MediaStore strategy to every OS version.
  • Filters MediaStore events 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 screencap and 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.