> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/screenshot-background-privacy-flutter.md).

# Screenshot Detection and Background Privacy in Flutter

How I separated screenshot detection, screen recording, Android capture prevention, and app-switcher privacy into testable Flutter contracts

## Result

In the Flutter app source I verified, “protecting the screen” was actually handled by several independent mechanisms:

* A service listened for screenshots and sent telemetry.
* A popup tried to find a new library image so the user could share it or send it to support.
* An optional WebView received a callback when a screenshot occurred.
* A widget applied a blur when the app entered `inactive`.

These parts address different needs. Together, they still do not provide a guarantee that content cannot be captured.

I reorganized the mental model into four contracts:

```
                 CAPTURE PRIVACY
                       │
      ┌────────────────┼─────────────────┐
      ▼                ▼                 ▼
screenshot event   active capture    lifecycle snapshot
 after/limited     start/stop state   inactive/background
      │                │                 │
      ▼                ▼                 ▼
 audit/support      redact/pause       privacy shield

Android secure-window prevention is a separate policy,
not an outcome of screenshot detection.
```

After separating them, the target architecture has exactly one owner for native capture signals:

```
Android/iOS callback
        │
        ▼
native adapter ─► CapturePrivacyCoordinator
                            │
                  normalize + dedupe
                            │
             ┌──────────────┼──────────────┐
             ▼              ▼              ▼
       surface policy   telemetry      support notice
             │           allowlist       optional
             ▼
  opaque shield / pause / secure-window request
```

Widgets do not create their own plugin listeners. Telemetry does not control security policy. A screenshot event is not proof that secure-window enforcement succeeded.

The result is a contract that can be tested at each layer:

* Screenshot and recording signals are normalized into typed events.
* Only one coordinator registers and cleans up native listeners.
* Each screen category has its own policy.
* Android enforcement returns `applied`, `unsupported`, or `failed`.
* App-switcher privacy uses a topmost shield instead of treating blur as a security promise.
* Telemetry contains no image, file path, route argument, or user identifier.
* Support and sharing start with an explicit user action and the system picker.

This is a proposed architecture based on source review, not a claim that the current app is production-ready. I did not run a device/OEM matrix, screen recording, AirPlay, app-switcher snapshot, or secure-window test during this verification.

## Problem

### Screenshot detection is not prevention

A screenshot callback only says that the OS or a heuristic observed an event. It does not answer three important questions by itself:

1. Did the callback arrive before or after the image was created?
2. Does it cover every capture method?
3. Did the OS actually block capture of the window?

On iOS, `userDidTakeScreenshotNotification` is posted after the screenshot is taken and contains neither an image nor a file path. The app can audit, warn, or offer support UX after the event, but the callback cannot revoke the image.

Android 14 provides the more standardized `Activity.ScreenCaptureCallback`. However, the API only reports documented screenshots made with a specific hardware-button combination. It does not return an image, and it does not detect screenshots taken through ADB or instrumentation tests.

For that reason, I do not name a shared abstraction `ScreenshotProtectionService`. That name easily mixes signals with enforcement. I separate `CaptureSignalAdapter` from `CaptureControlAdapter`.

### Multiple instances do not create multiple safe owners

The case study uses `screen_capture_event 1.2.0`. The app creates at least three `ScreenCaptureEvent` instances for telemetry, a support popup, and a WebView.

On the Dart side, each instance has its own callback list but shares one static `MethodChannel`. Every constructor installs a method handler on that channel, so a later instance can replace the handler installed by an earlier one.

The side effect goes beyond event delivery. Each constructor also requests Android storage permission by default in a fire-and-forget call. A widget that only wants to listen for an event can therefore trigger permission work during construction.

The native watcher also uses shared state. One consumer calling `dispose()` can stop a watcher another consumer expects. Below Android API 29, the package creates observers for multiple directories but repeatedly overwrites the same field, so cleanup retains a reference only to the last observer. Repeated `watch()` calls can also replace that field without first stopping the old watcher.

This is a structural ownership gap verified in source. I did not reproduce duplicate events, leaked watchers, or multiple permission dialogs on hardware, so I do not describe those as verified runtime outcomes.

### Blur on `inactive` is not the same as background protection

The privacy widget in the case study enables blur on `AppLifecycleState.inactive` and removes it on `resumed`. It does not handle `hidden`, `paused`, or `detached` directly.

`inactive` has a broader meaning than “the app is in the background.” Flutter may emit it when:

* The notification shade or Control Center is open.
* The app switcher is visible.
* A system dialog or biometric prompt owns focus.
* The app is visible in split screen or picture-in-picture without focus.
* A view transition is in progress.

Flutter also warns that applications must not rely on receiving every lifecycle notification. A Boolean named `isAppInactive` is therefore not expressive enough for a “protect the background snapshot” policy.

Z-order matters too. In the source I reviewed, the privacy overlay is not the final child in the root `Stack`; another popup can still paint above it. Blur also leaves data in the widget tree and may reveal shapes or text.

I treat blur as presentation for a lower-risk surface. A screen containing a short-lived secret uses an opaque topmost shield that is removed only after the app has genuinely returned to `resumed`.

### The support popup mixes events, permissions, and file ownership

The support popup requests photo permission after the first screenshot signal. Its logic only accepts `PermissionStatus.granted` and ignores `PermissionStatus.limited`, while its “already requested” flag still prevents retry on later screenshots.

The gallery callback obtains a filesystem path from `AssetEntity.file`. Its preview then calls `Image.asset(path)`, although `Image.asset` expects an asset-bundle key. This is an API mismatch verified in source; I did not run the device flow to verify the visible symptom.

The callback also crosses two `await` boundaries before calling `setState`, without rechecking `mounted` or the current generation. If the user leaves while the gallery resolves the asset, a late callback can target stale state.

More importantly, screenshot detection is not consent to read the newest image in the library. I separate the support flow like this:

```
screenshot signal ─► optional notice
                           │
                     user taps Share
                           │
                           ▼
                   system picker/share sheet
                           │
                    user selects asset
                           │
                           ▼
                 scoped file handle + cleanup
```

The user explicitly selects an asset. The app handles granted, limited, denied, and cancel outcomes. Every continuation after `await` checks `mounted` and the request generation, and the scoped file is cleaned up with the support request lifecycle.

### A MethodChannel `Future` needs a real completion contract

The case-study package has another mismatch between its Dart signature and native implementation:

* Android `watch`, `dispose`, permission, and secure-window methods do not complete `MethodChannel.Result`.
* iOS `watch` and `dispose` do not call `FlutterResult` either.
* The Dart wrapper drops the `Future` for `watch()` and `dispose()`, while permission and secure-window APIs return a `Future`.

The app does not currently `await` the two fire-and-forget operations, so the source does not expose a pending Future at those call sites. A new caller can still follow the Dart signature, await the secure-window Future, and never receive completion.

My public adapter does not leave this contract ambiguous. Every control operation must complete exactly once, have a timeout, and map failure into a typed outcome.

### Threat model and limitations

The architecture in this article aims to:

* Prevent multiple widgets from competing for the native listener.
* Keep sensitive metadata out of telemetry.
* Protect app-owned UI according to lifecycle and surface policy.
* React to active recording or mirroring where the OS provides a signal.
* Request Android secure-window behavior per screen and observe its enforcement outcome.

It does not stop an external camera, manual copying, a modified OS, root or jailbreak, process instrumentation, or every OEM capture path. Absence of a detection signal does not prove that no capture occurred. A detected signal does not prove user fraud either.

The article below separates root and jailbreak heuristics, OS attestation, and app shielding from surface privacy so each signal stays within its correct boundary.

{% content-ref url="/pages/0DLgCNDBcLATtINEcs10" %}
[Device Integrity and App Shielding in Flutter](/flutter/my-flutter/security-observability/device-integrity-app-shielding-flutter.md)
{% endcontent-ref %}

## Solution

### Separate ownership and contracts first

I start with an ownership table instead of a package API:

| Layer               | Owns                                                                    | Must not decide by itself                              |
| ------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
| Flutter surface     | Sensitivity class, presentation, and support UX                         | Whether native signal coverage is complete             |
| Capture coordinator | One subscription owner, dedupe, generation, fan-out, and cleanup        | That the OS blocked every capture                      |
| Signal adapter      | Activity or scene registration, unregistration, and event normalization | Business policy and analytics payload                  |
| Control adapter     | Secure-window request and typed enforcement outcome                     | That a screenshot callback means enforcement succeeded |
| Android/iOS         | Capture APIs, lifecycle, and window or scene behavior                   | Fraud verdicts or data-retention policy                |
| Telemetry           | Allowlisted schema, retention, access, and sampling                     | Authentication or account decisions                    |
| Support/share       | User consent, picker, file lifetime, and cleanup                        | Automatically reading the newest screenshot            |

The rule I keep is: **the coordinator unifies ownership and outcomes, but it does not make Android and iOS capabilities equivalent**.

### Define typed signals and control outcomes

The public model contains no screenshot bytes, file path, route, or user identifier:

```dart
import 'dart:async';

enum CaptureSignalKind {
  screenshotObserved,
  captureStarted,
  captureStopped,
  unavailable,
}

enum CaptureCoverage { supported, limited, unsupported, unknown }

final class CaptureSignal {
  const CaptureSignal({
    required this.kind,
    required this.coverage,
    required this.observedAt,
  });

  final CaptureSignalKind kind;
  final CaptureCoverage coverage;
  final DateTime observedAt;
}

abstract interface class CaptureSignalAdapter {
  Stream<CaptureSignal> get signals;
  Future<void> start();
  Future<void> stop();
}

enum SecureWindowOutcome { applied, unsupported, failed }

abstract interface class CaptureControlAdapter {
  Future<SecureWindowOutcome> setSecureWindow({required bool enabled});
}
```

Detection and control remain separate. `screenshotObserved` cannot be interpreted as `SecureWindowOutcome.applied`.

`applied` means the requested `enabled` state, whether `true` or `false`, was successfully applied. A platform without secure-window control can normalize a `false` request to `applied` because there is no control left to remove; a `true` request must return `unsupported`. Every enable or disable failure is surfaced.

`CaptureCoverage.limited` is also meaningful. Android callbacks, legacy heuristics, and iOS notifications have different coverage; the adapter does not turn all of them into `supported` merely to simplify the domain.

### Use an enum allowlist for surface policy

A `String surfaceClass` would still allow routes, account IDs, or arbitrary data to leak into telemetry. I use an enum and validate conflicting actions when a policy is created:

```dart
enum CaptureAction {
  allow,
  auditOnly,
  redactWhenObscured,
  hideWhileCaptured,
  requestSecureWindow,
}

enum CaptureSurfaceClass { public, account, secret }

final class CaptureSurfacePolicy {
  factory CaptureSurfacePolicy({
    required CaptureSurfaceClass surfaceClass,
    required Set<CaptureAction> actions,
  }) {
    if (actions.contains(CaptureAction.allow) && actions.length > 1) {
      throw ArgumentError('allow cannot be combined with protective actions');
    }
    return CaptureSurfacePolicy._(
      surfaceClass: surfaceClass,
      actions: Set.unmodifiable(actions),
    );
  }

  const CaptureSurfacePolicy._({
    required this.surfaceClass,
    required this.actions,
  });

  final CaptureSurfaceClass surfaceClass;
  final Set<CaptureAction> actions;
}
```

`allow` cannot appear with `requestSecureWindow` without defined precedence. If a product needs more complex policy, I still validate conflicts in one factory or reducer instead of letting each widget interpret a `Set` differently.

A neutral matrix can start like this:

| Surface | Screenshot event | Active capture            | App switcher      | Android prevention      |
| ------- | ---------------- | ------------------------- | ----------------- | ----------------------- |
| Public  | Ignore or audit  | Do not change UI          | Branding snapshot | Off                     |
| Account | Redacted audit   | Hide sensitive fields     | Privacy shield    | Product/security policy |
| Secret  | Minimal audit    | Hide or pause immediately | Opaque shield     | On when supported       |

eKYC is a clear example of an identity surface where data, lifecycle, and provider/backend boundaries must be classified before choosing capture policy. The following article explains those boundaries in detail; SEC-05 uses it only to identify a sensitive surface rather than repeating the eKYC flow.

{% content-ref url="/pages/qCO67hUmb5IbFezVsRGr" %}
[Secure Multi-Provider eKYC in Flutter](/flutter/my-flutter/security-observability/secure-multi-provider-ekyc-flutter.md)
{% endcontent-ref %}

### Create only one coordinator per Flutter engine

The coordinator owns the adapter, subscription, and operation queue. Consumers only receive state and events from it:

```dart
import 'dart:async';

enum _NativeRegistrationState { stopped, starting, started, stopPending }

final class CapturePrivacyCoordinator {
  CapturePrivacyCoordinator({
    required CaptureSignalAdapter signalAdapter,
    required CaptureControlAdapter controlAdapter,
  }) : _signalAdapter = signalAdapter,
       _controlAdapter = controlAdapter;

  final CaptureSignalAdapter _signalAdapter;
  final CaptureControlAdapter _controlAdapter;

  final StreamController<CaptureSignal> _events =
      StreamController<CaptureSignal>.broadcast();

  Stream<CaptureSignal> get events => _events.stream;

  Future<void> _operationTail = Future<void>.value();
  Future<void>? _disposeFuture;
  StreamSubscription<CaptureSignal>? _subscription;
  _NativeRegistrationState _nativeState = _NativeRegistrationState.stopped;
  bool _disposed = false;
  int _generation = 0;

  void _ensureUsable() {
    if (_disposed || _disposeFuture != null) {
      throw StateError('CapturePrivacyCoordinator is disposed');
    }
  }

  Future<T> _enqueue<T>(Future<T> Function() operation) {
    final completer = Completer<T>();
    _operationTail = _operationTail.then((_) async {
      try {
        completer.complete(await operation());
      } catch (error, stackTrace) {
        completer.completeError(error, stackTrace);
      }
    });
    return completer.future;
  }

  Future<void> start() => _enqueue(() async {
    _ensureUsable();
    if (_nativeState == _NativeRegistrationState.started) return;
    if (_nativeState == _NativeRegistrationState.stopPending) {
      throw StateError('Retry stop before starting a new registration');
    }

    final generation = ++_generation;
    _nativeState = _NativeRegistrationState.starting;
    final subscription = _signalAdapter.signals.listen((signal) {
      final acceptsSignals =
          _nativeState == _NativeRegistrationState.starting ||
          _nativeState == _NativeRegistrationState.started;
      if (!_disposed &&
          _disposeFuture == null &&
          acceptsSignals &&
          generation == _generation) {
        _events.add(signal);
      }
    });
    _subscription = subscription;

    try {
      await _signalAdapter.start().timeout(const Duration(seconds: 3));
      _nativeState = _NativeRegistrationState.started;
    } on Object catch (error, stackTrace) {
      _nativeState = _NativeRegistrationState.stopPending;
      _generation++;
      Error.throwWithStackTrace(error, stackTrace);
    }
  });

  Future<void> setPolicy(CaptureSurfacePolicy policy) => _enqueue(() async {
    _ensureUsable();
    if (_nativeState != _NativeRegistrationState.started) {
      throw StateError('Start capture coordination before applying a policy');
    }

    final shouldSecure = policy.actions.contains(
      CaptureAction.requestSecureWindow,
    );

    final outcome = await _controlAdapter
        .setSecureWindow(enabled: shouldSecure)
        .timeout(const Duration(seconds: 3));

    if (outcome != SecureWindowOutcome.applied) {
      throw StateError(
        'Secure-window update failed: enabled=$shouldSecure, outcome=$outcome',
      );
    }
  });

  Future<void> stop() => _enqueue(() async {
    if (_nativeState == _NativeRegistrationState.stopped) return;

    _nativeState = _NativeRegistrationState.stopPending;
    _generation++;
    await _signalAdapter.stop().timeout(const Duration(seconds: 3));
    await _subscription?.cancel();
    _subscription = null;
    _nativeState = _NativeRegistrationState.stopped;
  });

  Future<void> dispose() {
    if (_disposed) return Future<void>.value();
    final pending = _disposeFuture;
    if (pending != null) return pending;

    final operation = _disposeOnce();
    _disposeFuture = operation;
    return operation;
  }

  Future<void> _disposeOnce() async {
    try {
      await stop();
      _disposed = true;
      await _events.close();
    } catch (_) {
      _disposeFuture = null;
      rethrow;
    }
  }
}
```

The operation queue serializes `start`, policy updates, and `stop`. The generation guard rejects events from an old registration. If native stop times out or fails, the state remains `stopPending`, the subscription reference remains available for cleanup, and the next `stop()` call retries. `start()` rejects a new registration until the old stop succeeds. The coordinator also rejects `start()` and `setPolicy()` once disposal begins, so it cannot write to a closed stream.

A consumer only cancels its own subscription to `events`; it does not call native `stop()`. Only the composition root disposes the coordinator.

The example throws for a failed policy update to stay compact. Both enabling and disabling secure-window behavior must return `applied`; a disable failure must not be swallowed because stale enforcement state may remain. In a production app, I map timeout or failure to a state such as `enforcementFailed`, decide whether to fail closed or leave the screen according to policy, and log an allowlisted reason. I do not log the raw native error.

### Separate the lifecycle reducer from the privacy widget

Lifecycle state and capture state both affect presentation, but they are not the same signal:

```
resumed ─────────────────────► visible
   │                             │
   ├─ inactive + policy ──────► obscured
   │                             │
   └─ hidden/paused ──────────► backgroundProtected

captureStarted + hide policy ─► captureProtected

obscured/backgroundProtected/captureProtected
   └─ resumed + not captured + current generation ─► visible
```

A pure reducer lets me test transitions without a real OS callback:

```dart
import 'package:flutter/widgets.dart';

enum PrivacyPresentation { visible, obscured, protected }

PrivacyPresentation reducePrivacyPresentation({
  required AppLifecycleState lifecycle,
  required bool captureActive,
  required CaptureSurfacePolicy policy,
}) {
  final actions = policy.actions;

  if (captureActive && actions.contains(CaptureAction.hideWhileCaptured)) {
    return PrivacyPresentation.protected;
  }

  if (lifecycle == AppLifecycleState.hidden ||
      lifecycle == AppLifecycleState.paused ||
      lifecycle == AppLifecycleState.detached) {
    return policy.surfaceClass == CaptureSurfaceClass.public
        ? PrivacyPresentation.obscured
        : PrivacyPresentation.protected;
  }

  if (lifecycle == AppLifecycleState.inactive &&
      actions.contains(CaptureAction.redactWhenObscured)) {
    return policy.surfaceClass == CaptureSurfaceClass.secret
        ? PrivacyPresentation.protected
        : PrivacyPresentation.obscured;
  }

  return PrivacyPresentation.visible;
}
```

`inactive` only triggers protection when the policy requests it. The app does not call this state “background” in analytics or UI copy.

### Place the privacy shield at the top

The shield widget is the final child in the app-owned `Stack`:

```dart
import 'package:flutter/material.dart';

class PrivacyBoundary extends StatelessWidget {
  const PrivacyBoundary({
    required this.child,
    required this.presentation,
    super.key,
  });

  final Widget child;
  final PrivacyPresentation presentation;

  @override
  Widget build(BuildContext context) {
    final isProtected = presentation != PrivacyPresentation.visible;

    return Stack(
      fit: StackFit.expand,
      children: [
        ExcludeSemantics(
          excluding: isProtected,
          child: IgnorePointer(ignoring: isProtected, child: child),
        ),
        if (isProtected)
          const Positioned.fill(
            child: ColoredBox(
              color: Color(0xFF111318),
              child: ExcludeSemantics(
                child: Center(child: FlutterLogo(size: 48)),
              ),
            ),
          ),
      ],
    );
  }
}
```

A secret surface uses an opaque shield. If an account or public surface uses blur, I treat that as a lower-risk presentation choice rather than data removal.

When the shield is active, the underlying app tree ignores pointer input and is removed from semantics. App-switcher privacy must not leave sensitive labels or values available through the accessibility tree. The app can add a localized neutral label for the shield, but that label must contain no screen or user data.

When the app returns, the reducer removes the shield only after `resumed` and after reconciling the current capture state.

### Android: detection and enforcement are separate paths

On Android 14 and later, the official Activity callback is the primary detection path. The manifest declares its install-time permission:

```xml
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
```

The Activity registers the callback while visible and unregisters it when stopping:

```kotlin
import android.app.Activity
import android.os.Build
import androidx.annotation.RequiresApi
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity() {
    private var screenCaptureCallback: Any? = null

    override fun onStart() {
        super.onStart()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            registerCaptureSignal()
        }
    }

    override fun onStop() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            unregisterCaptureSignal()
        }
        super.onStop()
    }

    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    private fun registerCaptureSignal() {
        if (screenCaptureCallback != null) return

        val callback = Activity.ScreenCaptureCallback {
            // Emit a semantic event; do not read or send screenshot bytes.
        }
        screenCaptureCallback = callback
        registerScreenCaptureCallback(mainExecutor, callback)
    }

    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    private fun unregisterCaptureSignal() {
        val callback = screenCaptureCallback as? Activity.ScreenCaptureCallback
            ?: return
        unregisterScreenCaptureCallback(callback)
        screenCaptureCallback = null
    }
}
```

The OS shows a notice when the callback detects a screenshot. Product copy should explain this before using the API on a screen so the user is not surprised.

For earlier Android versions, a legacy heuristic is retained only when its owner:

* Measures coverage on a real API and OEM matrix.
* Has a clear permission rationale.
* Retains references to every observer and stops all of them.
* Tests repeated start and stop, Activity recreation, and engine disposal.
* Has a removal plan instead of describing the fallback as fully supported.

I do not request broad photo or storage access solely to emit screenshot telemetry when the product does not need media access.

Secure-window control is a different native method:

```kotlin
import android.view.WindowManager

private fun setSecureWindow(enabled: Boolean) {
    if (enabled) {
        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
    } else {
        window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
    }
}
```

I demonstrate `FLAG_SECURE` for a secret surface rather than enabling it across the entire app. It trades off with casting, user support, and sharing, and it still does not stop an external camera or a compromised device.

`setRecentsScreenshotEnabled(false)`, available from API 33, is a Recents-snapshot policy. Android documentation states that the system may still screenshot the Activity in other contexts, so this API does not replace `FLAG_SECURE`.

### iOS: screenshots are events, capture is state

iOS needs three separate paths:

| Need                                               | Contract                                                                                                       |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Know that the user took a screenshot               | Observe `userDidTakeScreenshotNotification`; do not expect an image or path                                    |
| Know that the screen is being recorded or mirrored | Observe scene capture state through the current API; use availability gates and fallback for older OS versions |
| Protect the app switcher                           | Replace sensitive UI through scene or application lifecycle before the snapshot                                |

`UIScreen.isCaptured` and the older notification can report recording, mirroring, or AirPlay state, but Apple now directs developers toward scene capture state. The adapter chooses an API for the deployment target and SDK being built; the domain only receives `captureStarted` and `captureStopped`.

UIKit captures a UI snapshot after the app or scene enters the background and uses it in the app switcher. The privacy shield must already be available before that snapshot; it must not begin loading an asset when the background callback arrives.

iOS must not be described as having a generic secure-window API equivalent to `FLAG_SECURE` for all Flutter UI. The app reacts according to available signals and specific content types, and documents the limitation.

### Telemetry uses a typed allowlist

My maximum public payload looks like this:

```json
{
  "signal": "screenshot_observed",
  "surface_class": "secret",
  "platform": "ios",
  "os_bucket": "current",
  "coverage": "limited",
  "policy_outcome": "redacted"
}
```

I do not include:

* Screenshot images, bytes, or filesystem paths.
* Routes, queries, WebView URLs or callbacks, or serialized arguments.
* Account IDs, email addresses, phone numbers, document numbers, advertising IDs, or session tokens.
* Scroll coordinates without a concrete use case and privacy review.
* Raw native exceptions that may contain paths or screen metadata.

A capture signal is a product, support, or risk signal. It does not lock an account, reject an operation, or become a fraud verdict by itself.

Capture events must cross the same sanitizer and allowlist boundary as crash, HTTP, and navigation telemetry. The following Sentry article explains ownership of that pipeline; SEC-05 only defines the minimal capture payload.

{% content-ref url="/pages/ATOHUDA9JCzn2wmCbZD9" %}
[Sentry for Crashes and Tracing](/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md)
{% endcontent-ref %}

### Support and sharing use an explicit picker

Support UX begins only after an explicit user action. The picker returns a file or URI according to the platform contract; the preview uses `Image.file`, `FileImage`, or the provider matching the returned object, not `Image.asset` for a runtime path.

After every `await`, the callback checks `mounted` and the request generation:

```dart
Future<void> pickSupportImage() async {
  final generation = ++_pickGeneration;
  final picked = await picker.pickImage();

  if (!mounted || generation != _pickGeneration || picked == null) {
    return;
  }

  setState(() => _selectedImage = picked);
}

@override
void dispose() {
  _pickGeneration++;
  _selectedImage?.release();
  super.dispose();
}
```

`picker`, `_pickGeneration`, `_selectedImage`, and `release()` are illustrative abstractions. The real adapter must define file ownership, copied or cache location, expiry, and cleanup. It must not copy the internal contract into a public sample.

### Verify the result

I split verification into four layers.

#### 1. Dart ownership and policy

* Calling `start()` twice registers native code once.
* Calling `stop()` twice is safe and unregisters once.
* A native stop timeout or failure keeps `stopPending`; the next stop retries and only moves to `stopped` after success.
* `start()` in `stopPending` is rejected instead of creating a second registration over the old watcher.
* `start()` and `setPolicy()` are rejected after disposal begins; the stream receives no event after close.
* Multiple consumers do not create more native adapters.
* An event from an old generation does not mutate new state.
* Combining `allow` with a protective action is rejected.
* Secure-window `unsupported`, `failed`, and timeout outcomes are not changed into `applied`.
* A failed or timed-out secure-window disable surfaces an error; only a successful retry proves that stale state was removed.

#### 2. Lifecycle, widget, and support UX

* `resumed → inactive → hidden → paused → resumed` retains and removes the shield according to policy.
* A skipped lifecycle state does not flash a secret surface on the first foreground frame.
* Biometric prompts, system dialogs, and the notification shade use the `obscured` policy rather than being mislabeled as background.
* Root z-order always keeps the shield as the final app-owned layer.
* While the shield is active, pointers do not reach the underlying child and sensitive semantics are absent from the accessibility tree.
* The picker handles granted, limited, denied, cancel, and callbacks arriving after disposal.
* A canary route, path, or user ID does not enter telemetry.

#### 3. Android native and device

| Case                                    | Expected verification                                                                |
| --------------------------------------- | ------------------------------------------------------------------------------------ |
| API 34+ hardware-button screenshot      | Callback belongs to the correct Activity; no image or path is expected               |
| ADB or instrumentation screenshot       | Record the official limitation rather than treating a missing callback as an app bug |
| API 24–28 legacy fallback               | Every observer retains a reference and is cleaned up after repeated start and stop   |
| API 29/30/33/34+ and multiple OEMs      | Measure coverage instead of inferring from one emulator                              |
| `FLAG_SECURE` on and off                | Secret surface and share or cast UX follow policy                                    |
| Recents, multi-window, and PiP          | No first-frame leak or stale shield                                                  |
| Activity recreation and engine disposal | No stale Activity, duplicate listener, or leaked watcher                             |
| MethodChannel methods                   | Every call completes its result once or returns a typed timeout or failure           |

#### 4. iOS native and device

| Case                                               | Expected verification                                                   |
| -------------------------------------------------- | ----------------------------------------------------------------------- |
| Hardware screenshot                                | Notification arrives after the event; telemetry does not require a path |
| Screen recording start and stop                    | Secret UI hides or pauses and restores for the current generation       |
| AirPlay or mirroring                               | Capture state maps correctly when the device and setup support it       |
| App switcher, Home, and lock or unlock             | Snapshot contains no sensitive surface                                  |
| Control Center, biometric prompt, and system alert | The `inactive` policy does not break the flow                           |
| Repeated observer start and stop                   | Result completion and cleanup remain idempotent                         |
| Scene lifecycle                                    | The correct scene is protected if the app expands to multiple scenes    |

For this verification, I only had source, configuration, and dependency evidence:

* I read the app-level services, widgets, call sites, and native source for the exact locked package version.
* I found no focused tests for the screenshot tracker, support popup, background blur, or native capture callback.
* The local dependency cache did not contain the package even though package configuration had an entry, so I did not treat a full build or analysis as evidence.
* I did not run screenshots, recording, AirPlay, app-switcher, multi-window, PiP, Activity recreation, or process death on a device.
* I did not verify analytics-backend deduplication, retention, or access control.

The device and OEM matrix is therefore a production-assurance gate. It does not block describing the architecture, but it blocks claims such as “complete detection,” “screenshots are impossible,” or “production-ready.”

### Common mistakes and trade-offs

#### Using a screenshot callback to claim capture was blocked

Callbacks and enforcement are separate contracts. Only the control-adapter outcome says whether the secure-window request was applied, and even that outcome is not an absolute guarantee.

#### Creating a plugin instance in every widget

The code looks local while ownership of the native channel is global. Move the listener to an app-level coordinator and let widgets subscribe to its stream.

#### Requesting photo permission in a constructor or detection callback

The permission appears without user context, and limited or denied outcomes have no clear UX. Open the picker only after an explicit user action.

#### Treating blur as data removal

Blur keeps data in the widget tree and may reveal shapes. A secret surface uses an opaque shield; memory, storage, and logging need separate policies.

#### Calling every `inactive` state background

Biometric prompts, the notification shade, and system dialogs can also make the app inactive. Use a sensitivity-aware reducer and test real interruptions.

#### Enabling `FLAG_SECURE` for the entire app without reviewing UX

A blanket flag is simple but can break sharing, casting, and support flows. Enable it for secret surfaces when policy allows and return a typed outcome.

#### Keeping a legacy watcher without a removal plan

A folder and permission heuristic increases maintenance debt without proving coverage. Measure the version and OEM matrix, test cleanup, and set a removal date.

#### Returning a `Future` while native code never completes the result

The caller can wait forever. The native method completes exactly once; the Dart adapter adds a timeout and maps failures into typed outcomes.

### Verified versions and scope

* Flutter source baseline: 3.41.2.
* Dart SDK constraint: 3.11.0 up to, but not including, 4.0.0.
* Android app: minimum SDK 24, compile and target SDK 36.
* iOS app: deployment target 15.0.
* Case-study package: `screen_capture_event 1.2.0` from the dependency lock and exact archive source.
* Android 14/API 34+: the official screenshot-detection callback is the proposed primary path.

This version list only describes the source snapshot I researched. When upgrading Flutter, target SDK, Xcode or iOS SDK, or the capture package, verify API availability, permissions, manifest merging, Activity or scene lifecycle, MethodChannel completion, and the device matrix again.

### References

* [Flutter — AppLifecycleState](https://api.flutter.dev/flutter/dart-ui/AppLifecycleState.html)
* [Flutter — Image.asset](https://api.flutter.dev/flutter/widgets/Image/Image.asset.html)
* [Android — Detect when users take device screenshots](https://developer.android.com/about/versions/14/features/screenshot-detection)
* [Android — Activity.setRecentsScreenshotEnabled](https://developer.android.com/reference/android/app/Activity#setRecentsScreenshotEnabled\(boolean\))
* [Android — Storage use cases and best practices](https://developer.android.com/training/data-storage/use-cases)
* [Apple — userDidTakeScreenshotNotification](https://developer.apple.com/documentation/uikit/uiapplication/userdidtakescreenshotnotification)
* [Apple — capturedDidChangeNotification](https://developer.apple.com/documentation/uikit/uiscreen/captureddidchangenotification)
* [Apple — UIScreen.isCaptured](https://developer.apple.com/documentation/uikit/uiscreen/iscaptured)
* [Apple — UISceneCaptureState](https://developer.apple.com/documentation/uikit/uiscenecapturestate)
* [Apple — Preparing your UI to run in the background](https://developer.apple.com/documentation/uikit/preparing-your-ui-to-run-in-the-background)
* [screen\_capture\_event 1.2.0](https://pub.dev/packages/screen_capture_event/versions/1.2.0)
* [OWASP MASWE-0005 — Sensitive Data in Logs](https://mas.owasp.org/MASWE/MASVS-STORAGE/MASWE-0005/)

## Conclusion

I no longer use one phrase, “screenshot protection,” for four different problems. A screenshot event is a signal with limited coverage. Recording and mirroring are states with a start and stop. App-switcher privacy depends on lifecycle and a topmost shield. Android secure-window behavior is a separate control operation with its own outcome.

The architecture keeps one native owner, a typed surface policy, generation guards, and a telemetry allowlist. A secret surface uses an opaque shield; `FLAG_SECURE` is requested only by policy; support and sharing start with an explicit picker instead of reading the newest image automatically.

This approach gives code clear ownership and testable failure modes. It does not make Flutter content “impossible to copy.” Before claiming production assurance, you still need a device and OEM matrix for screenshots, recording, mirroring, app-switcher snapshots, Activity or scene recreation, and every supported platform version.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
