image_privacy_scrubber 0.1.0 copy "image_privacy_scrubber: ^0.1.0" to clipboard
image_privacy_scrubber: ^0.1.0 copied to clipboard

Offline Flutter package that inspects and removes privacy-sensitive image metadata (EXIF, GPS, XMP, IPTC, comments) from JPEG and PNG without altering pixel data or uploading images.

example/lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_privacy_scrubber/image_privacy_scrubber.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import 'io_write.dart';

void main() {
  runApp(const ImagePrivacyExampleApp());
}

class ImagePrivacyExampleApp extends StatelessWidget {
  const ImagePrivacyExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Image Privacy Scrubber',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1B4D3E)),
        useMaterial3: true,
      ),
      home: const ScrubberHomePage(),
    );
  }
}

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

  @override
  State<ScrubberHomePage> createState() => _ScrubberHomePageState();
}

class _ScrubberHomePageState extends State<ScrubberHomePage> {
  static const _disclaimer =
      'Metadata removed. Visible information inside the image, such as faces, '
      'text, IDs, and addresses, is not altered.';

  final _picker = ImagePicker();

  Uint8List? _originalBytes;
  Uint8List? _scrubbedBytes;
  ImagePrivacyReport? _report;
  ScrubResult? _scrubResult;
  String? _status;
  bool _busy = false;

  Future<void> _pickImage() async {
    setState(() {
      _busy = true;
      _status = null;
    });
    try {
      final file = await _picker.pickImage(source: ImageSource.gallery);
      if (file == null) {
        setState(() => _status = 'No image selected.');
        return;
      }
      final bytes = await file.readAsBytes();
      setState(() {
        _originalBytes = bytes;
        _scrubbedBytes = null;
        _report = null;
        _scrubResult = null;
        _status = 'Image loaded (${bytes.length} bytes). Nothing was uploaded.';
      });
    } catch (e) {
      setState(() => _status = 'Failed to pick image: $e');
    } finally {
      setState(() => _busy = false);
    }
  }

  Future<void> _inspect() async {
    final bytes = _originalBytes;
    if (bytes == null) return;
    setState(() {
      _busy = true;
      _status = null;
    });
    try {
      final report = ImagePrivacyScrubber.inspectBytes(bytes);
      setState(() {
        _report = report;
        if (report.isUnsupported) {
          _status =
              'Format detected but scrubbing unsupported: ${report.unsupportedReason}';
        } else if (report.hasSensitiveMetadata) {
          _status = 'Sensitive metadata found.';
        } else {
          _status = 'No sensitive metadata found.';
        }
      });
    } catch (e) {
      setState(() => _status = 'Inspect failed: $e');
    } finally {
      setState(() => _busy = false);
    }
  }

  Future<void> _scrub() async {
    final bytes = _originalBytes;
    if (bytes == null) return;
    setState(() {
      _busy = true;
      _status = null;
    });
    try {
      final result = ImagePrivacyScrubber.scrubBytes(bytes);
      final removed = result.removed.map((e) => e.name).join(', ');
      setState(() {
        _scrubResult = result;
        _scrubbedBytes = result.bytes;
        _report = result.after;
        _status =
            'Scrubbed offline. Removed: ${removed.isEmpty ? '(none)' : removed}';
      });
    } catch (e) {
      setState(() => _status = 'Scrub failed: $e');
    } finally {
      setState(() => _busy = false);
    }
  }

  Future<void> _saveCleanCopy() async {
    final bytes = _scrubbedBytes;
    if (bytes == null) return;
    if (kIsWeb) {
      setState(() {
        _status =
            'Web export is a local placeholder — scrubbed bytes stay in memory only. Nothing was uploaded.';
      });
      return;
    }
    try {
      final dir = await getApplicationDocumentsDirectory();
      final output = p.join(
        dir.path,
        'privacy_scrubbed_${DateTime.now().millisecondsSinceEpoch}.img',
      );
      await writeBytesToPath(output, bytes);
      setState(() {
        _status =
            'Saved clean copy on device (offline).\n$output\nNothing was uploaded.';
      });
    } catch (e) {
      setState(() => _status = 'Save failed: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Image Privacy Scrubber')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(
            'Inspect and remove privacy-sensitive metadata offline. '
            'This demo never uploads your images.',
            style: Theme.of(context).textTheme.bodyLarge,
          ),
          const SizedBox(height: 16),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton.icon(
                onPressed: _busy ? null : _pickImage,
                icon: const Icon(Icons.photo_library_outlined),
                label: const Text('Choose image'),
              ),
              FilledButton.tonalIcon(
                onPressed: _busy || _originalBytes == null ? null : _inspect,
                icon: const Icon(Icons.search),
                label: const Text('Inspect'),
              ),
              FilledButton.tonalIcon(
                onPressed: _busy || _originalBytes == null ? null : _scrub,
                icon: const Icon(Icons.cleaning_services_outlined),
                label: const Text('Scrub metadata'),
              ),
              OutlinedButton.icon(
                onPressed: _busy || _scrubbedBytes == null
                    ? null
                    : _saveCleanCopy,
                icon: const Icon(Icons.save_alt),
                label: const Text('Save clean copy'),
              ),
            ],
          ),
          if (_busy) ...[
            const SizedBox(height: 16),
            const LinearProgressIndicator(),
          ],
          if (_status != null) ...[const SizedBox(height: 16), Text(_status!)],
          if (_scrubResult != null) ...[
            const SizedBox(height: 16),
            Card(
              color: Theme.of(context).colorScheme.secondaryContainer,
              child: const Padding(
                padding: EdgeInsets.all(12),
                child: Text(_disclaimer),
              ),
            ),
            const SizedBox(height: 8),
            Text('Removed: ${_joinKinds(_scrubResult!.removed)}'),
            Text('Retained: ${_joinKinds(_scrubResult!.retained)}'),
          ],
          const SizedBox(height: 16),
          _FindingsPanel(report: _report),
          const SizedBox(height: 16),
          if (_originalBytes != null)
            _ImagePreview(title: 'Original', bytes: _originalBytes!),
          if (_scrubbedBytes != null) ...[
            const SizedBox(height: 16),
            _ImagePreview(title: 'Scrubbed', bytes: _scrubbedBytes!),
          ],
        ],
      ),
    );
  }

  String _joinKinds(Set<MetadataKind> kinds) {
    if (kinds.isEmpty) return '(none)';
    return kinds.map((e) => e.name).join(', ');
  }
}

class _FindingsPanel extends StatelessWidget {
  const _FindingsPanel({required this.report});

  final ImagePrivacyReport? report;

  @override
  Widget build(BuildContext context) {
    if (report == null) {
      return const Text('Privacy findings will appear after Inspect or Scrub.');
    }
    final r = report!;
    final highlights = <String>[];
    if (r.hasKind(MetadataKind.gps)) {
      highlights.add('GPS location found');
    }
    if (r.hasKind(MetadataKind.camera) || r.hasKind(MetadataKind.device)) {
      highlights.add('Camera/device metadata found');
    }
    if (r.hasKind(MetadataKind.author) || r.hasKind(MetadataKind.comment)) {
      highlights.add('Author/comment metadata found');
    }
    if (highlights.isEmpty && !r.hasSensitiveMetadata) {
      highlights.add('No sensitive metadata found');
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Privacy findings',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        Text('Format: ${r.format.name}'),
        const SizedBox(height: 8),
        ...highlights.map(
          (b) => ListTile(
            dense: true,
            contentPadding: EdgeInsets.zero,
            leading: Icon(
              r.hasSensitiveMetadata
                  ? Icons.warning_amber_rounded
                  : Icons.verified_user_outlined,
            ),
            title: Text(b),
          ),
        ),
        ...r.items.map(
          (item) => ListTile(
            dense: true,
            contentPadding: EdgeInsets.zero,
            title: Text(item.label),
            subtitle: Text(
              '${item.kind.name} · ${item.severity.name}'
              '${item.description == null ? '' : ' · ${item.description}'}',
            ),
          ),
        ),
      ],
    );
  }
}

class _ImagePreview extends StatelessWidget {
  const _ImagePreview({required this.title, required this.bytes});

  final String title;
  final Uint8List bytes;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title, style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: Image.memory(
            bytes,
            fit: BoxFit.contain,
            errorBuilder: (context, error, stackTrace) =>
                const Text('Preview unavailable for this format.'),
          ),
        ),
      ],
    );
  }
}
0
likes
150
points
83
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Offline Flutter package that inspects and removes privacy-sensitive image metadata (EXIF, GPS, XMP, IPTC, comments) from JPEG and PNG without altering pixel data or uploading images.

Repository (GitHub)
View/report issues

Topics

#privacy #image #exif #metadata #security

License

MIT (license)

Dependencies

cross_file, flutter

More

Packages that depend on image_privacy_scrubber