diff --git a/lib/features/analysis/analysis_provider.dart b/lib/features/analysis/analysis_provider.dart index d2773f07..1bd1beb6 100644 --- a/lib/features/analysis/analysis_provider.dart +++ b/lib/features/analysis/analysis_provider.dart @@ -239,12 +239,15 @@ class AnalysisProvider extends ChangeNotifier { } /// Exporte l'image et le json vers le backend IA. - /// [sessionId], [distance] et [weapon] proviennent du SessionProvider de + /// [sessionId], [distance] et [caliber] proviennent du SessionProvider de /// l'écran appelant, pour que le dataset contienne les vraies métadonnées. + /// + /// Le nom de l'arme n'est volontairement pas transmis : il est souvent + /// personnalisé par l'utilisateur et n'apporte rien au modèle de détection. Future exportToAiBackend({ String? sessionId, int? distance, - String? weapon, + String? caliber, }) async { if (_imagePath == null || _targetType == null) { _errorMessage = "Impossible d'exporter : image ou type de cible manquant."; @@ -269,7 +272,7 @@ class AnalysisProvider extends ChangeNotifier { targetRadius: _targetRadius, shots: _shots, distanceMeters: distance ?? 25, - weaponName: weapon ?? 'Unknown', + caliber: caliber ?? 'unknown', ); _state = AnalysisState.success; diff --git a/lib/features/analysis/analysis_screen.dart b/lib/features/analysis/analysis_screen.dart index 00e0550c..0421ed11 100644 --- a/lib/features/analysis/analysis_screen.dart +++ b/lib/features/analysis/analysis_screen.dart @@ -942,7 +942,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> { exportResult = await provider.exportToAiBackend( sessionId: sessionProvider.activeSessionId, distance: sessionProvider.distance, - weapon: sessionProvider.currentWeapon, + caliber: sessionProvider.currentWeaponCaliber, ); messenger.hideCurrentSnackBar(); } diff --git a/lib/features/session/session_provider.dart b/lib/features/session/session_provider.dart index b5257973..dedbf169 100644 --- a/lib/features/session/session_provider.dart +++ b/lib/features/session/session_provider.dart @@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier { DateTime? get sessionDate => _sessionDate; String? _currentWeaponName; String? _currentWeaponId; + String? _currentWeaponCaliber; int _shotsPerTarget = 5; int _distance = 25; String? _activeSessionId; @@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier { String? get currentWeapon => _currentWeaponName; String? get currentWeaponId => _currentWeaponId; + + /// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne + /// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au + /// backend d'entrainement (le diametre des trous en depend). + String? get currentWeaponCaliber => _currentWeaponCaliber; int get shotsPerTarget => _shotsPerTarget; int get distance => _distance; bool get isSessionActive => _isSessionActive; @@ -23,9 +29,10 @@ class SessionProvider extends ChangeNotifier { int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); int get targetCount => _currentAnalyses.length; - void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) { + void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) { _currentWeaponName = weaponName; _currentWeaponId = weaponId; + _currentWeaponCaliber = caliber; _shotsPerTarget = shots; _distance = distance; _sessionDate = date ?? DateTime.now(); @@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier { _activeSessionId = null; _currentWeaponId = null; _currentWeaponName = null; + _currentWeaponCaliber = null; _currentAnalyses.clear(); _distance = 25; notifyListeners(); diff --git a/lib/features/session/session_setup_screen.dart b/lib/features/session/session_setup_screen.dart index 0a22e62c..639689dd 100644 --- a/lib/features/session/session_setup_screen.dart +++ b/lib/features/session/session_setup_screen.dart @@ -140,6 +140,7 @@ class _SessionSetupScreenState extends State { _shotsPerTarget, sessionId, weaponId: _selectedWeapon!.id, + caliber: _selectedWeapon!.caliber, distance: _distance, date: _selectedDate, ); diff --git a/lib/services/ai_export_service.dart b/lib/services/ai_export_service.dart index 7db259b6..881b10a7 100644 --- a/lib/services/ai_export_service.dart +++ b/lib/services/ai_export_service.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'dart:io'; import 'package:http/http.dart' as http; -import 'package:device_info_plus/device_info_plus.dart'; +import 'package:image/image.dart' as img; import 'package:crypto/crypto.dart'; import '../data/models/shot.dart'; import '../data/models/target_type.dart'; @@ -64,30 +64,37 @@ class AiExportResult { } class AiExportService { - /// Extrait les informations de l'appareil - Future> _getDeviceInfo() async { - final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); - Map deviceData = {'model': 'Unknown', 'os': 'Unknown'}; - + /// Retire toutes les metadonnees de la photo avant l'envoi. + /// + /// Une photo de cible prise au telephone embarque un bloc EXIF qui contient + /// typiquement la position GPS du stand de tir, la date exacte et le modele + /// d'appareil. Rien de tout cela n'est utile au modele de detection. + /// + /// L'orientation est d'abord appliquee physiquement aux pixels : les + /// coordonnees d'impact sont normalisees sur l'image telle qu'elle est + /// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer + /// le tag sans redresser l'image ferait pivoter la photo par rapport a ses + /// propres annotations. + /// + /// Retourne null si l'image est illisible. + @visibleForTesting + Uint8List? stripMetadata(Uint8List originalBytes) { try { - if (Platform.isAndroid) { - final androidInfo = await deviceInfoPlugin.androidInfo; - deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}'; - deviceData['os'] = 'Android ${androidInfo.version.release}'; - } else if (Platform.isIOS) { - final iosInfo = await deviceInfoPlugin.iosInfo; - deviceData['model'] = iosInfo.name; - deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}'; - } else if (Platform.isWindows) { - final windowsInfo = await deviceInfoPlugin.windowsInfo; - deviceData['model'] = 'Windows PC'; - deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}'; - } - } catch (e) { - debugPrint('Erreur lors de la récupération des infos appareil: $e'); - } + final decoded = img.decodeImage(originalBytes); + if (decoded == null) return null; - return deviceData; + final baked = img.bakeOrientation(decoded); + + // bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset, + // le GPS survivrait au reencodage. + baked.exif = img.ExifData(); + + return img.encodeJpg(baked, quality: 90); + } catch (e) { + // Sur un fichier tronque, decodeImage leve au lieu de retourner null. + debugPrint('Photo illisible, export annule: $e'); + return null; + } } /// Exporte l'image et les données de plotting vers le serveur @@ -100,7 +107,7 @@ class AiExportService { required double targetRadius, required List shots, int distanceMeters = 25, - String weaponName = 'Unknown', + String caliber = 'unknown', String? apiUrl, }) async { try { @@ -121,7 +128,13 @@ class AiExportService { ); } - final deviceData = await _getDeviceInfo(); + final sanitizedPhoto = stripMetadata(await file.readAsBytes()); + if (sanitizedPhoto == null) { + return AiExportResult.error( + code: 'INVALID_IMAGE', + message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.', + ); + } final corners = [ {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, @@ -155,11 +168,10 @@ class AiExportService { "session_id": sessionId, "wallet_hash": walletHash, "timestamp": DateTime.now().toIso8601String(), - "device_info": deviceData, "target_metadata": { "type": targetType.name, "distance_meters": distanceMeters, - "weapon": weaponName, + "caliber": caliber, }, "plotting": { "target_corners": corners, @@ -169,7 +181,11 @@ class AiExportService { request.fields['plotting'] = jsonEncode(plottingJson); request.files.add( - await http.MultipartFile.fromPath('photo', imagePath), + http.MultipartFile.fromBytes( + 'photo', + sanitizedPhoto, + filename: 'target.jpg', + ), ); final streamedResponse = await request.send().timeout( diff --git a/test/services/ai_export_service_test.dart b/test/services/ai_export_service_test.dart new file mode 100644 index 00000000..89355b0b --- /dev/null +++ b/test/services/ai_export_service_test.dart @@ -0,0 +1,61 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +import 'package:bully/services/ai_export_service.dart'; + +/// Construit un JPEG porteur de métadonnées EXIF comme le ferait un téléphone : +/// position GPS, modèle d'appareil et orientation. +Uint8List _photoWithExif({int orientation = 1}) { + final image = img.Image(width: 40, height: 20); + img.fill(image, color: img.ColorRgb8(120, 120, 120)); + + image.exif.imageIfd['Model'] = 'Pixel 8'; + image.exif.imageIfd.orientation = orientation; + image.exif.gpsIfd['GPSLatitude'] = 48.8584; + image.exif.gpsIfd['GPSLongitude'] = 2.2945; + + return img.encodeJpg(image); +} + +void main() { + final service = AiExportService(); + + group('stripMetadata', () { + test('supprime le GPS et les autres métadonnées EXIF', () { + final original = _photoWithExif(); + + // Garde-fou : sans EXIF au départ, le test ne prouverait rien. + expect(img.decodeImage(original)!.exif.isEmpty, isFalse); + + final stripped = service.stripMetadata(original); + final result = img.decodeImage(stripped!)!; + + expect(result.exif.isEmpty, isTrue); + expect(result.exif.gpsIfd.isEmpty, isTrue); + }); + + test('applique l\'orientation aux pixels avant de retirer le tag', () { + // Orientation 6 = rotation de 90°, donc les dimensions s'inversent. + final original = _photoWithExif(orientation: 6); + + final result = img.decodeImage(service.stripMetadata(original)!)!; + + expect(result.width, 20); + expect(result.height, 40); + expect(result.exif.isEmpty, isTrue); + }); + + test('conserve les dimensions quand il n\'y a pas d\'orientation', () { + final result = img.decodeImage(service.stripMetadata(_photoWithExif())!)!; + + expect(result.width, 40); + expect(result.height, 20); + }); + + test('retourne null sur une image illisible', () { + expect(service.stripMetadata(Uint8List.fromList([1, 2, 3, 4])), isNull); + }); + }); +}