From 4e3d7de32ccab1860a98445804e7be4b34af6092 Mon Sep 17 00:00:00 2001 From: Julius Joska Date: Sat, 3 Oct 2026 22:43:43 +0200 Subject: [PATCH] fix(app): strip EXIF and text metadata from sent images The image library copies EXIF through resize and orientation baking, and the JPEG/PNG encoders write it back out. Re-encoding alone therefore did not remove metadata: sent photos and their thumbnails could carry GPS coordinates, the camera model and the capture time. - Clear EXIF and text chunks on both the full image and the thumbnail right before encoding. - Treat decoder exceptions on malformed data like an undecodable image. - Add canStripMetadata() so the UI can warn before a format the library cannot read (HEIC, RAW) goes out as an untouched document. --- app/lib/core/services/image_processor.dart | 32 +++++++++++- app/test/image_processor_exif_test.dart | 59 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/test/image_processor_exif_test.dart diff --git a/app/lib/core/services/image_processor.dart b/app/lib/core/services/image_processor.dart index d1b8221..6ae2377 100644 --- a/app/lib/core/services/image_processor.dart +++ b/app/lib/core/services/image_processor.dart @@ -43,6 +43,19 @@ class ImageProcessor { /// sealed-sender obálku) — držet výrazně pod tím. static const int thumbMaxBytes = 48 * 1024; + /// Jestli z obrázku umíme odstranit metadata. Formát, který knihovna + /// nepřečte (HEIC z galerie odeslaný jako dokument, RAW…), by odešel + /// v originále včetně GPS — UI se proto nejdřív zeptá uživatele. + static Future canStripMetadata(Uint8List bytes) { + return Isolate.run(() { + try { + return img.decodeImage(bytes) != null; + } on Object { + return false; + } + }); + } + /// Zpracuje obrázek v izolátu (dekódování megapixelů by blokovalo UI). /// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je /// pak pošle jako dokument. @@ -51,7 +64,13 @@ class ImageProcessor { String? mimeHint, }) { return Isolate.run(() { - final decoded = img.decodeImage(original); + final img.Image? decoded; + try { + decoded = img.decodeImage(original); + } on Object { + // Dekodéry na poškozených datech háží místo null. + return null; + } if (decoded == null) return null; // GIF beze změny — re-encode by zabil animaci @@ -77,6 +96,7 @@ class ImageProcessor { // Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot, // canvas render z editoru fotek) je jako JPEG několikanásobně menší. final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded); + stripMetadata(image); final bytes = keepPng ? Uint8List.fromList(img.encodePng(image)) : Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality)); @@ -101,6 +121,15 @@ class ImageProcessor { return false; } + /// Zahodí EXIF (GPS, model telefonu, čas focení) a textová metadata. + /// Knihovna je při ořezu i zmenšení kopíruje a encodeJpg/encodePng je + /// znovu zapíše — re-encode sám o sobě metadata NEodstraní. + static void stripMetadata(img.Image image) { + image + ..exif = img.ExifData() + ..textData = null; + } + static Uint8List _thumbnail(img.Image source) { var thumb = max(source.width, source.height) > thumbDimension ? (source.width >= source.height @@ -114,6 +143,7 @@ class ImageProcessor { img.compositeImage(flat, thumb); thumb = flat; } + stripMetadata(thumb); var quality = 70; var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality)); // Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme diff --git a/app/test/image_processor_exif_test.dart b/app/test/image_processor_exif_test.dart new file mode 100644 index 0000000..d1e1568 --- /dev/null +++ b/app/test/image_processor_exif_test.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:sepot/core/services/image_processor.dart'; + +/// Fotka z telefonu nese v EXIF polohu (GPS), model telefonu a čas focení. +/// Nic z toho nesmí odejít — ani v odeslané fotce, ani v jejím náhledu. +void main() { + Uint8List photoWithGps({int width = 1200, int height = 800}) { + final photo = img.Image(width: width, height: height); + photo.exif.imageIfd['Make'] = 'TestPhone'; + photo.exif.gpsIfd['GPSLatitude'] = img.IfdValueRational(50, 1); + return Uint8List.fromList(img.encodeJpg(photo)); + } + + bool hasExif(Uint8List jpeg) { + final exif = img.decodeJpgExif(jpeg); + return exif != null && + (exif.imageIfd['Make'] != null || exif.gpsIfd['GPSLatitude'] != null); + } + + test('vstup testu opravdu nese EXIF i GPS', () { + expect(hasExif(photoWithGps()), isTrue); + }); + + test('odeslaná fotka je bez EXIF a GPS', () async { + final out = + await ImageProcessor.process(photoWithGps(), mimeHint: 'image/jpeg'); + expect(hasExif(out!.bytes), isFalse); + }); + + test('zmenšovaná fotka je bez EXIF a GPS', () async { + final out = await ImageProcessor.process( + photoWithGps(width: 4000, height: 3000), + mimeHint: 'image/jpeg'); + expect(hasExif(out!.bytes), isFalse); + }); + + test('náhled je bez EXIF a GPS', () async { + final out = + await ImageProcessor.process(photoWithGps(), mimeHint: 'image/jpeg'); + expect(hasExif(out!.thumbnailJpeg), isFalse); + }); + + test('JPEG umíme zbavit metadat', () async { + expect(await ImageProcessor.canStripMetadata(photoWithGps()), isTrue); + }); + + // Hlavička kontejneru HEIF z iPhonu; knihovna ho nedekóduje, takže by + // fotka odešla v originále — UI se musí zeptat. + test('HEIC metadat zbavit neumíme', () async { + final heic = Uint8List.fromList([ + 0, 0, 0, 24, ...'ftypheic'.codeUnits, 0, 0, 0, 0, + ...'mif1heic'.codeUnits, ...List.filled(64, 0), + ]); + expect(await ImageProcessor.canStripMetadata(heic), isFalse); + }); +}