Compare commits

...
7 changed files with 139 additions and 35 deletions
+6 -3
View File
@@ -239,12 +239,15 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Exporte l'image et le json vers le backend IA. /// 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. /// 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<AiExportResult> exportToAiBackend({ Future<AiExportResult> exportToAiBackend({
String? sessionId, String? sessionId,
int? distance, int? distance,
String? weapon, String? caliber,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant."; _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
@@ -269,7 +272,7 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25, distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown', caliber: caliber ?? 'unknown',
); );
_state = AnalysisState.success; _state = AnalysisState.success;
+2 -2
View File
@@ -784,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// qu'elle ressorte sur le bouton. // qu'elle ressorte sur le bouton.
color: Colors.white, color: Colors.white,
), ),
label: 'TERMINER TOUT ET EXPORTER', label: 'TERMINER ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor, color: AppTheme.warningColor,
onPressed: () => onPressed: () =>
_finishSession(context, provider, export: true), _finishSession(context, provider, export: true),
@@ -942,7 +942,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
exportResult = await provider.exportToAiBackend( exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId, sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance, distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon, caliber: sessionProvider.currentWeaponCaliber,
); );
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
} }
+9 -1
View File
@@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier {
DateTime? get sessionDate => _sessionDate; DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName; String? _currentWeaponName;
String? _currentWeaponId; String? _currentWeaponId;
String? _currentWeaponCaliber;
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
int _distance = 25; int _distance = 25;
String? _activeSessionId; String? _activeSessionId;
@@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier {
String? get currentWeapon => _currentWeaponName; String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId; 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 shotsPerTarget => _shotsPerTarget;
int get distance => _distance; int get distance => _distance;
bool get isSessionActive => _isSessionActive; 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 totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length; 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; _currentWeaponName = weaponName;
_currentWeaponId = weaponId; _currentWeaponId = weaponId;
_currentWeaponCaliber = caliber;
_shotsPerTarget = shots; _shotsPerTarget = shots;
_distance = distance; _distance = distance;
_sessionDate = date ?? DateTime.now(); _sessionDate = date ?? DateTime.now();
@@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier {
_activeSessionId = null; _activeSessionId = null;
_currentWeaponId = null; _currentWeaponId = null;
_currentWeaponName = null; _currentWeaponName = null;
_currentWeaponCaliber = null;
_currentAnalyses.clear(); _currentAnalyses.clear();
_distance = 25; _distance = 25;
notifyListeners(); notifyListeners();
@@ -140,6 +140,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
_shotsPerTarget, _shotsPerTarget,
sessionId, sessionId,
weaponId: _selectedWeapon!.id, weaponId: _selectedWeapon!.id,
caliber: _selectedWeapon!.caliber,
distance: _distance, distance: _distance,
date: _selectedDate, date: _selectedDate,
); );
+16 -1
View File
@@ -462,7 +462,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Text( const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.', '📤 Ce qui est envoyé : la photo de la cible, la position des '
'impacts que vous avez placés, la distance de tir et le calibre.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🚫 Ce qui ne l\'est pas : la géolocalisation de la photo (retirée '
'avant l\'envoi), le nom de votre arme, et le modèle de votre appareil.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🔒 Pseudonymat : votre identité est remplacée par un hash '
'cryptographique. Il reste le même d\'un envoi à l\'autre, afin de '
'rattacher vos contributions à votre compte (récompenses, '
'modération, suppression sur demande).',
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
+44 -28
View File
@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; 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 'package:crypto/crypto.dart';
import '../data/models/shot.dart'; import '../data/models/shot.dart';
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
@@ -64,30 +64,37 @@ class AiExportResult {
} }
class AiExportService { class AiExportService {
/// Extrait les informations de l'appareil /// Retire toutes les metadonnees de la photo avant l'envoi.
Future<Map<String, dynamic>> _getDeviceInfo() async { ///
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); /// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'}; /// 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 { try {
if (Platform.isAndroid) { final decoded = img.decodeImage(originalBytes);
final androidInfo = await deviceInfoPlugin.androidInfo; if (decoded == null) return null;
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');
}
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 /// Exporte l'image et les données de plotting vers le serveur
@@ -100,7 +107,7 @@ class AiExportService {
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25, int distanceMeters = 25,
String weaponName = 'Unknown', String caliber = 'unknown',
String? apiUrl, String? apiUrl,
}) async { }) async {
try { 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 = [ final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -155,11 +168,10 @@ class AiExportService {
"session_id": sessionId, "session_id": sessionId,
"wallet_hash": walletHash, "wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(), "timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": distanceMeters, "distance_meters": distanceMeters,
"weapon": weaponName, "caliber": caliber,
}, },
"plotting": { "plotting": {
"target_corners": corners, "target_corners": corners,
@@ -169,7 +181,11 @@ class AiExportService {
request.fields['plotting'] = jsonEncode(plottingJson); request.fields['plotting'] = jsonEncode(plottingJson);
request.files.add( request.files.add(
await http.MultipartFile.fromPath('photo', imagePath), http.MultipartFile.fromBytes(
'photo',
sanitizedPhoto,
filename: 'target.jpg',
),
); );
final streamedResponse = await request.send().timeout( final streamedResponse = await request.send().timeout(
+61
View File
@@ -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);
});
});
}