244 lines
8.0 KiB
Dart
244 lines
8.0 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'dart:io';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:crypto/crypto.dart';
|
|
import '../data/models/shot.dart';
|
|
import '../data/models/target_type.dart';
|
|
import 'wallet_identity_service.dart';
|
|
|
|
/// Résultat détaillé de l'exportation vers le serveur IA
|
|
class AiExportResult {
|
|
final bool isSuccess;
|
|
final String code;
|
|
final String message;
|
|
final String? reason;
|
|
final bool isBanned;
|
|
final Map<String, dynamic>? targetValidation;
|
|
|
|
AiExportResult({
|
|
required this.isSuccess,
|
|
required this.code,
|
|
required this.message,
|
|
this.reason,
|
|
this.isBanned = false,
|
|
this.targetValidation,
|
|
});
|
|
|
|
factory AiExportResult.success({
|
|
String? message,
|
|
Map<String, dynamic>? targetValidation,
|
|
}) {
|
|
return AiExportResult(
|
|
isSuccess: true,
|
|
code: 'UPLOAD_SUCCESS',
|
|
message: message ?? 'Export réussi vers le serveur IA !',
|
|
targetValidation: targetValidation,
|
|
);
|
|
}
|
|
|
|
factory AiExportResult.banned({
|
|
String? reason,
|
|
String? message,
|
|
}) {
|
|
return AiExportResult(
|
|
isSuccess: false,
|
|
code: 'WALLET_BANNED',
|
|
isBanned: true,
|
|
reason: reason,
|
|
message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
|
);
|
|
}
|
|
|
|
factory AiExportResult.error({
|
|
String? code,
|
|
required String message,
|
|
}) {
|
|
return AiExportResult(
|
|
isSuccess: false,
|
|
code: code ?? 'UPLOAD_ERROR',
|
|
message: message,
|
|
);
|
|
}
|
|
}
|
|
|
|
class AiExportService {
|
|
/// 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 {
|
|
final decoded = img.decodeImage(originalBytes);
|
|
if (decoded == null) return null;
|
|
|
|
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
|
|
Future<AiExportResult> exportData({
|
|
required String imagePath,
|
|
required String sessionId,
|
|
required TargetType targetType,
|
|
required double targetCenterX,
|
|
required double targetCenterY,
|
|
required double targetRadius,
|
|
required List<Shot> shots,
|
|
int distanceMeters = 25,
|
|
String caliber = 'unknown',
|
|
String? apiUrl,
|
|
}) async {
|
|
try {
|
|
final walletService = WalletIdentityService();
|
|
final rawBaseUrl = await walletService.getServerBaseUrl();
|
|
final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl;
|
|
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
|
|
final url = Uri.parse(effectiveUrl);
|
|
final request = http.MultipartRequest('POST', url);
|
|
request.headers['X-API-KEY'] = WalletIdentityService.apiKey;
|
|
|
|
// 1. Prepare image
|
|
final file = File(imagePath);
|
|
if (!await file.exists()) {
|
|
return AiExportResult.error(
|
|
code: 'FILE_NOT_FOUND',
|
|
message: 'Le fichier image cible est introuvable.',
|
|
);
|
|
}
|
|
|
|
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},
|
|
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
|
|
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius},
|
|
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius},
|
|
];
|
|
|
|
// Format the impacts
|
|
final formattedImpacts = shots.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final shot = entry.value;
|
|
return {
|
|
"id": index + 1,
|
|
"label": "bullet_hole",
|
|
"score": shot.score,
|
|
"coords": {
|
|
"norm_x": shot.x,
|
|
"norm_y": shot.y
|
|
}
|
|
};
|
|
}).toList();
|
|
|
|
// Get and hash the wallet identity
|
|
final phrase = await walletService.getIdentityPhrase();
|
|
final phraseBytes = utf8.encode(phrase);
|
|
final walletHash = sha256.convert(phraseBytes).toString();
|
|
|
|
// Build JSON payload
|
|
final plottingJson = {
|
|
"session_id": sessionId,
|
|
"wallet_hash": walletHash,
|
|
"timestamp": DateTime.now().toIso8601String(),
|
|
"target_metadata": {
|
|
"type": targetType.name,
|
|
"distance_meters": distanceMeters,
|
|
"caliber": caliber,
|
|
},
|
|
"plotting": {
|
|
"target_corners": corners,
|
|
"impacts": formattedImpacts
|
|
}
|
|
};
|
|
|
|
request.fields['plotting'] = jsonEncode(plottingJson);
|
|
request.files.add(
|
|
http.MultipartFile.fromBytes(
|
|
'photo',
|
|
sanitizedPhoto,
|
|
filename: 'target.jpg',
|
|
),
|
|
);
|
|
|
|
final streamedResponse = await request.send().timeout(
|
|
const Duration(seconds: 15),
|
|
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
|
|
);
|
|
|
|
final responseBody = await streamedResponse.stream.bytesToString();
|
|
Map<String, dynamic> responseJson = {};
|
|
try {
|
|
responseJson = jsonDecode(responseBody);
|
|
} catch (_) {}
|
|
|
|
final statusCode = streamedResponse.statusCode;
|
|
|
|
if (statusCode == 200) {
|
|
debugPrint('Export réussi: $responseBody');
|
|
return AiExportResult.success(
|
|
message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
|
|
targetValidation: responseJson['target_validation'] as Map<String, dynamic>?,
|
|
);
|
|
} else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
|
|
final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
|
|
debugPrint('Export rejeté (banni): $reason');
|
|
// Persister le bannissement localement et couper l'envoi de photos
|
|
await walletService.setBanned(true, reason: reason);
|
|
return AiExportResult.banned(
|
|
reason: reason,
|
|
message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
|
|
);
|
|
} else if (statusCode == 400) {
|
|
return AiExportResult.error(
|
|
code: responseJson['code'] ?? 'BAD_REQUEST',
|
|
message: responseJson['error'] ?? 'Requête d\'export invalide.',
|
|
);
|
|
} else {
|
|
return AiExportResult.error(
|
|
code: responseJson['code'] ?? 'SERVER_ERROR',
|
|
message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
|
|
);
|
|
}
|
|
} on SocketException {
|
|
return AiExportResult.error(
|
|
code: 'NETWORK_ERROR',
|
|
message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Exception lors de l\'export: $e');
|
|
return AiExportResult.error(
|
|
code: 'UNKNOWN_ERROR',
|
|
message: 'Erreur lors de l\'export: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|