image_privacy_scrubber 0.1.0
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.'),
),
),
],
);
}
}