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.
This commit is contained in:
@@ -43,6 +43,19 @@ class ImageProcessor {
|
|||||||
/// sealed-sender obálku) — držet výrazně pod tím.
|
/// sealed-sender obálku) — držet výrazně pod tím.
|
||||||
static const int thumbMaxBytes = 48 * 1024;
|
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<bool> 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).
|
/// 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
|
/// Vrací null, když bajty nejsou dekódovatelný obrázek — volající je
|
||||||
/// pak pošle jako dokument.
|
/// pak pošle jako dokument.
|
||||||
@@ -51,7 +64,13 @@ class ImageProcessor {
|
|||||||
String? mimeHint,
|
String? mimeHint,
|
||||||
}) {
|
}) {
|
||||||
return Isolate.run(() {
|
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;
|
if (decoded == null) return null;
|
||||||
|
|
||||||
// GIF beze změny — re-encode by zabil animaci
|
// 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,
|
// Samotný alfa kanál nestačí — plně krytý RGBA PNG (screenshot,
|
||||||
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
// canvas render z editoru fotek) je jako JPEG několikanásobně menší.
|
||||||
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
final keepPng = mimeHint == 'image/png' && _hasTransparency(decoded);
|
||||||
|
stripMetadata(image);
|
||||||
final bytes = keepPng
|
final bytes = keepPng
|
||||||
? Uint8List.fromList(img.encodePng(image))
|
? Uint8List.fromList(img.encodePng(image))
|
||||||
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
: Uint8List.fromList(img.encodeJpg(image, quality: jpegQuality));
|
||||||
@@ -101,6 +121,15 @@ class ImageProcessor {
|
|||||||
return false;
|
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) {
|
static Uint8List _thumbnail(img.Image source) {
|
||||||
var thumb = max(source.width, source.height) > thumbDimension
|
var thumb = max(source.width, source.height) > thumbDimension
|
||||||
? (source.width >= source.height
|
? (source.width >= source.height
|
||||||
@@ -114,6 +143,7 @@ class ImageProcessor {
|
|||||||
img.compositeImage(flat, thumb);
|
img.compositeImage(flat, thumb);
|
||||||
thumb = flat;
|
thumb = flat;
|
||||||
}
|
}
|
||||||
|
stripMetadata(thumb);
|
||||||
var quality = 70;
|
var quality = 70;
|
||||||
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
var encoded = Uint8List.fromList(img.encodeJpg(thumb, quality: quality));
|
||||||
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
// Náhled je jen placeholder, než dorazí ostrá fotka — kvalitu držíme
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user