screenshot_shield 0.2.0 copy "screenshot_shield: ^0.2.0" to clipboard
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.

example/lib/main.dart

import 'dart:async';
import 'dart:typed_data';

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

final RouteObserver<ModalRoute<void>> routeObserver =
    RouteObserver<ModalRoute<void>>();

void main() {
  runApp(
    ScreenshotShieldScope(
      shield: ScreenshotShield(),
      routeObserver: routeObserver,
      child: const MaterialApp(
        title: 'Screenshot Shield Demo',
        home: HomeScreen(),
      ),
    ),
  );
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final ScreenshotShield shield = ScreenshotShield();
  StreamSubscription<bool>? screenRecordingSubscription;
  bool preventCapture = true;
  bool detectScreenshots = true;
  bool backgroundBlur = false;
  DateTime? lastDetected;
  Uint8List? lastImage;
  bool isScreenRecording = false;
  int screenRecordingEvents = 0;

  @override
  void initState() {
    super.initState();
    // Read the current state first: the stream only delivers *changes*, so a screen
    // that appears while a recording is already running would otherwise show
    // "not recording" until the next change.
    isScreenRecording = shield.isScreenRecording;
    screenRecordingSubscription = shield.onScreenRecordingChanged.listen((
      value,
    ) {
      if (!mounted) {
        return;
      }
      setState(() {
        isScreenRecording = value;
        screenRecordingEvents++;
      });
    });
  }

  @override
  void dispose() {
    screenRecordingSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScreenshotShieldRouteGuard(
      preventCapture: preventCapture,
      detectScreenshots: detectScreenshots,
      captureOnScreenshot: true,
      onScreenshotDetected: (image) {
        setState(() {
          lastDetected = DateTime.now();
          lastImage = image;
        });
      },
      child: Scaffold(
        appBar: AppBar(title: const Text('Screenshot Shield Demo')),
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            const _SectionLabel('Guarded content'),
            Container(
              height: 180,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: Colors.indigo,
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Text(
                'This screen is protected.\nTry taking a screenshot.',
                textAlign: TextAlign.center,
                style: TextStyle(color: Colors.white, fontSize: 16),
              ),
            ),
            const SizedBox(height: 16),
            SwitchListTile(
              title: const Text('Prevent capture'),
              subtitle: const Text(
                'Blanks the captured frame (Android, iOS and Windows)',
              ),
              value: preventCapture,
              onChanged: (value) => setState(() => preventCapture = value),
            ),
            SwitchListTile(
              title: const Text('Detect screenshots'),
              subtitle: const Text('Report onScreenshotDetected events'),
              value: detectScreenshots,
              onChanged: (value) => setState(() => detectScreenshots = value),
            ),
            SwitchListTile(
              title: const Text('Background blur'),
              subtitle: const Text(
                "Hide the app's content in the app switcher",
              ),
              value: backgroundBlur,
              onChanged: (value) async {
                setState(() => backgroundBlur = value);
                await shield.setProtection(backgroundBlur: value);
              },
            ),
            const SizedBox(height: 16),
            const _SectionLabel('Detection'),
            Card(
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      lastDetected == null
                          ? 'No screenshot detected yet.'
                          : 'Detected at ${lastDetected!.toLocal()}',
                    ),
                    if (lastImage != null) ...[
                      const SizedBox(height: 8),
                      Image.memory(
                        lastImage!,
                        height: 160,
                        fit: BoxFit.contain,
                      ),
                    ],
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),
            const _SectionLabel('Screen recording'),
            Card(
              child: ListTile(
                leading: Icon(
                  isScreenRecording
                      ? Icons.fiber_manual_record
                      : Icons.videocam_off_outlined,
                  color: isScreenRecording ? Colors.red : null,
                ),
                title: Text(
                  isScreenRecording
                      ? 'The screen is being recorded'
                      : 'The screen is not being recorded',
                ),
                subtitle: Text(
                  'Android 15+, iOS (the iOS simulator always reports "not '
                  'recording"); best-effort on Windows and Linux · '
                  '${screenRecordingEvents == 0 ? 'no state events yet' : '$screenRecordingEvents state event(s)'}',
                ),
              ),
            ),
            const SizedBox(height: 16),
            FilledButton.tonal(
              onPressed: () => Navigator.of(context).push(
                MaterialPageRoute<void>(builder: (_) => const SecondScreen()),
              ),
              child: const Text('Open region shielding demo'),
            ),
          ],
        ),
      ),
    );
  }
}

class _SectionLabel extends StatelessWidget {
  const _SectionLabel(this.text);

  final String text;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(text, style: Theme.of(context).textTheme.titleMedium),
    );
  }
}

class SecondScreen extends StatefulWidget {
  const SecondScreen({super.key});

  @override
  State<SecondScreen> createState() => _SecondScreenState();
}

class _SecondScreenState extends State<SecondScreen> {
  final TextEditingController _text = TextEditingController();
  Timer? _ticker;
  int _seconds = 0;
  SensitiveProtection _protection = SensitiveProtection.whileCaptured;
  bool _obscureKeyboard = false;
  // `null` is the package default (about 30 refreshes a second).
  Duration? _animationRefresh;

  @override
  void initState() {
    super.initState();
    // Something inside the region that keeps changing, to show that the copy the
    // user sees tracks the live subtree instead of freezing.
    _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
      if (mounted) {
        setState(() => _seconds++);
      }
    });
  }

  @override
  void dispose() {
    _ticker?.cancel();
    _text.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final ScreenshotShield shield = ScreenshotShieldScope.of(context);
    return Scaffold(
      appBar: AppBar(title: const Text('Region shielding')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          const Text(
            'By default this region is the original widget - nothing wrapped, '
            'nothing rasterised - and it is only kept out of captures while the '
            'screen is recorded or mirrored, or while the app is in the background '
            '(which also keeps it out of the app-switcher snapshot).',
          ),
          const SizedBox(height: 8),
          SegmentedButton<SensitiveProtection>(
            segments: const <ButtonSegment<SensitiveProtection>>[
              ButtonSegment<SensitiveProtection>(
                value: SensitiveProtection.whileRecording,
                label: Text('Recording only'),
              ),
              ButtonSegment<SensitiveProtection>(
                value: SensitiveProtection.whileCaptured,
                label: Text('+ background'),
              ),
              ButtonSegment<SensitiveProtection>(
                value: SensitiveProtection.always,
                label: Text('Always'),
              ),
            ],
            selected: <SensitiveProtection>{_protection},
            onSelectionChanged: (Set<SensitiveProtection> selection) =>
                setState(() => _protection = selection.first),
          ),
          const SizedBox(height: 8),
          Text(switch (_protection) {
            SensitiveProtection.whileRecording =>
              'Recording only: the app switcher and foreground screenshots are '
                  'not covered.',
            SensitiveProtection.whileCaptured =>
              'Recording plus background: also keeps the region out of the '
                  'app-switcher snapshot.',
            SensitiveProtection.always =>
              'Always: foreground screenshots are blanked too, at the cost of '
                  'always showing a live copy of the subtree.',
          }),
          const Text('Ordinary content (captured normally):'),
          const SizedBox(height: 8),
          Container(
            height: 64,
            alignment: Alignment.center,
            color: Colors.teal,
            child: const Text(
              'Ordinary content',
              style: TextStyle(color: Colors.white, fontSize: 18),
            ),
          ),
          const SizedBox(height: 24),
          const Text('Sensitive region:'),
          const SizedBox(height: 8),
          ScreenshotShieldSensitiveView(
            protection: _protection,
            // What a capture shows, and what the user sees behind the copy.
            captureColor: Colors.black,
            child: Container(
              padding: const EdgeInsets.all(12),
              color: Colors.deepOrange,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Live counter: $_seconds s · Expires 09/29',
                    style: const TextStyle(color: Colors.white, fontSize: 16),
                  ),
                  const SizedBox(height: 8),
                  TextField(
                    controller: _text,
                    style: const TextStyle(color: Colors.white),
                    cursorColor: Colors.white,
                    decoration: const InputDecoration(
                      isDense: true,
                      hintText: 'Tap and type: input works normally',
                      hintStyle: TextStyle(color: Colors.white70),
                      enabledBorder: OutlineInputBorder(
                        borderSide: BorderSide(color: Colors.white54),
                      ),
                      focusedBorder: OutlineInputBorder(
                        borderSide: BorderSide(color: Colors.white),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 24),
          const _SectionLabel('Animating region'),
          const Text(
            'A region whose content never stops repainting. While it is engaged '
            '(recording, or "Always" above), each refresh rasterises it and reads it '
            'back from the GPU, so the refresh rate trades smoothness for cost. Record '
            'the screen: the card must stay black in the recording at every rate.',
          ),
          const SizedBox(height: 8),
          SegmentedButton<Duration?>(
            segments: const <ButtonSegment<Duration?>>[
              ButtonSegment<Duration?>(
                value: Duration.zero,
                label: Text('Every frame'),
              ),
              ButtonSegment<Duration?>(
                value: null,
                label: Text('30 fps (default)'),
              ),
              ButtonSegment<Duration?>(
                value: Duration(milliseconds: 100),
                label: Text('10 fps'),
              ),
            ],
            selected: <Duration?>{_animationRefresh},
            onSelectionChanged: (Set<Duration?> selection) =>
                setState(() => _animationRefresh = selection.first),
          ),
          const SizedBox(height: 8),
          ScreenshotShieldSensitiveView(
            protection: _protection,
            refreshInterval: _animationRefresh,
            // The card has rounded corners: show the page colour behind them while
            // the region is engaged.
            backdropColor: Theme.of(context).colorScheme.surface,
            // What screenshots and recordings show instead of the card: a rounded
            // box with a lock, matching the card's shape.
            capturePlaceholder: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.black,
                borderRadius: BorderRadius.circular(16),
              ),
              child: const Center(
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.lock, color: Colors.white70),
                    SizedBox(width: 8),
                    Text('Hidden', style: TextStyle(color: Colors.white70)),
                  ],
                ),
              ),
            ),
            child: const _AnimatedBalanceCard(),
          ),
          const SizedBox(height: 24),
          const _SectionLabel('Keyboard'),
          const Text(
            'The keyboard is a separate system window, so neither a guarded screen '
            'nor a region covers it by itself. Focus each field below and take a '
            'screenshot: if the keyboard is missing for one of them, this platform '
            'already hides it for secure input.',
          ),
          const SizedBox(height: 8),
          const TextField(
            decoration: InputDecoration(
              border: OutlineInputBorder(),
              labelText: 'Normal field',
              helperText: 'Is the keyboard in the screenshot?',
            ),
          ),
          const SizedBox(height: 12),
          const TextField(
            obscureText: true,
            decoration: InputDecoration(
              border: OutlineInputBorder(),
              labelText: 'Secure field (obscureText)',
              helperText: 'Is the keyboard in the screenshot?',
            ),
          ),
          const SizedBox(height: 8),
          SwitchListTile(
            title: const Text('Obscure the keyboard in captures'),
            subtitle: const Text(
              'iOS only: keeps the keyboard usable on screen while captures get '
              'none of its pixels. Android cannot do this - the keyboard belongs to '
              'another app - so the call is a no-op there.',
            ),
            value: _obscureKeyboard,
            onChanged: (bool value) {
              setState(() => _obscureKeyboard = value);
              shield.setKeyboardProtection(enabled: value);
            },
          ),
          const SizedBox(height: 24),
          const Text(
            'Try it: the counter ticks and typing works. Start a screen recording '
            'from Control Center and this region comes out black in the recording '
            'while the app on screen keeps showing it; background the app and the '
            'app-switcher card is black too. A foreground screenshot is not covered '
            'unless the switch above is on - screens that must come out blank use '
            'whole-window prevention, which is what the home screen of this demo '
            'does.',
          ),
        ],
      ),
    );
  }
}

/// A card that animates continuously: a sweeping gradient, a progress bar and a
/// spinner, so the region repaints on every frame.
class _AnimatedBalanceCard extends StatefulWidget {
  const _AnimatedBalanceCard();

  @override
  State<_AnimatedBalanceCard> createState() => _AnimatedBalanceCardState();
}

class _AnimatedBalanceCardState extends State<_AnimatedBalanceCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 2),
  )..repeat();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (BuildContext context, Widget? child) {
        final double t = _controller.value;
        return Container(
          height: 120,
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(16),
            gradient: LinearGradient(
              begin: Alignment(-1 + 2 * t, -1),
              end: Alignment(1 - 2 * t, 1),
              colors: const <Color>[Colors.indigo, Colors.purple, Colors.pink],
            ),
          ),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    const Text(
                      'Balance',
                      style: TextStyle(color: Colors.white70),
                    ),
                    Text(
                      '\u20a6 ${(1250000 + t * 1000).toStringAsFixed(2)}',
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 22,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    LinearProgressIndicator(
                      value: t,
                      backgroundColor: Colors.white24,
                      color: Colors.white,
                    ),
                  ],
                ),
              ),
              const SizedBox(width: 16),
              Transform.rotate(
                angle: t * 6.283185307179586,
                child: const Icon(
                  Icons.autorenew,
                  color: Colors.white,
                  size: 40,
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}
2
likes
160
points
655
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Detect screenshots and screen recording, blank screen captures of a whole screen or a sensitive region, and hide your app in the app switcher.

Repository (GitHub)
View/report issues

Topics

#screenshot #screen-recording #security #privacy #screen-capture

License

MIT (license)

Dependencies

flutter, meta, plugin_platform_interface

More

Packages that depend on screenshot_shield

Packages that implement screenshot_shield