import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:file_saver/file_saver.dart'; import 'package:file_selector/file_selector.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum MemoryImportMode { merge, replace } class MemoryExportResult { const MemoryExportResult({ required this.entryCount, required this.destination, }); final int entryCount; final String destination; } class MemoryImportCandidate { const MemoryImportCandidate({ required this.sourcePlatform, required this.sourceDeviceId, required this.exportedAtUtc, required this.values, }); final String sourcePlatform; final String sourceDeviceId; final DateTime? exportedAtUtc; final Map values; int get entryCount => values.length; } class MemoryImportResult { const MemoryImportResult({ required this.importedCount, required this.removedCount, required this.mode, }); final int importedCount; final int removedCount; final MemoryImportMode mode; } /// Sauvegarde portable de la mémoire comportementale de YouTube Personal. /// /// Important : ce service utilise volontairement une liste blanche. Les /// identifiants Google, tokens OAuth, secrets, chemins de cache et autres /// données propres à une plateforme ne sont jamais exportés. class MemoryBackupService { MemoryBackupService._(); static final MemoryBackupService instance = MemoryBackupService._(); static const String formatId = 'youtube-personal-memory'; static const int schemaVersion = 1; static const String _deviceIdKey = 'youtube_personal.sync.device_id.v1'; static const String _lastImportKey = 'youtube_personal.sync.last_import_utc.v1'; static const Set _allowedExactKeys = { 'youtube_personal.history.v1', 'youtube_personal.favorites.v1', 'youtube_personal.positions.v1', 'youtube_personal.watch_counts.v1', 'youtube_personal.hidden_channels.v1', // Ces deux réglages influencent directement les recommandations. 'youtube_personal.settings.preferred_language.v1', 'youtube_personal.settings.young_audience_filter.v1', }; static const List _allowedPrefixes = [ 'youtube_personal.radar.', 'youtube_personal.interest_', ]; final SharedPreferencesAsync _preferences = SharedPreferencesAsync(); Future memoryEntryCount() async { final all = await _preferences.getAll(); return all.keys.where(_isMemoryKey).length; } Future exportMemory() async { final all = await _preferences.getAll(); final memory = {}; for (final entry in all.entries) { if (!_isMemoryKey(entry.key)) { continue; } memory[entry.key] = entry.value; } final deviceId = await _getOrCreateDeviceId(); final now = DateTime.now().toUtc(); final payload = { 'format': formatId, 'schemaVersion': schemaVersion, 'exportedAtUtc': now.toIso8601String(), 'sourcePlatform': Platform.operatingSystem, 'sourceDeviceId': deviceId, 'entryCount': memory.length, 'preferences': { for (final entry in memory.entries) entry.key: _encodePreference(entry.value), }, }; final jsonText = const JsonEncoder.withIndent(' ').convert(payload); final bytes = Uint8List.fromList(utf8.encode(jsonText)); final fileName = 'YouTube_Personal_Memory_${_fileTimestamp(now)}.json'; final baseName = fileName.substring( 0, fileName.length - '.ypmemory'.length, ); final destination = await FileSaver.instance.saveAs( name: baseName, bytes: bytes, fileExtension: 'json', mimeType: MimeType.custom, customMimeType: 'application/json', ); if (destination == null || destination.trim().isEmpty) { return null; } return MemoryExportResult( entryCount: memory.length, destination: destination, ); } Future pickMemoryBackup() async { final XFile? selected; if (Platform.isAndroid) { // Android ne mappe pas toujours l'extension personnalisée .ypmemory // vers un type MIME. Le contenu est validé strictement juste après. selected = await openFile(); } else { const memoryFiles = XTypeGroup( label: 'Mémoire YouTube Personal', extensions: ['json', 'ypmemory'], ); selected = await openFile(acceptedTypeGroups: [memoryFiles]); } if (selected == null) { return null; } final bytes = await selected.readAsBytes(); if (bytes.isEmpty) { throw const FormatException('Impossible de lire le fichier sélectionné.'); } final rawText = utf8.decode(bytes, allowMalformed: false); final decoded = jsonDecode(rawText); if (decoded is! Map) { throw const FormatException('Sauvegarde mémoire invalide.'); } final map = decoded.map((key, value) => MapEntry('$key', value)); if (map['format'] != formatId) { throw const FormatException( 'Ce fichier n’est pas une sauvegarde YouTube Personal.', ); } final rawSchema = map['schemaVersion']; final schema = rawSchema is num ? rawSchema.toInt() : -1; if (schema <= 0 || schema > schemaVersion) { throw FormatException( 'Version de sauvegarde non prise en charge : $schema.', ); } final rawPreferences = map['preferences']; if (rawPreferences is! Map) { throw const FormatException('La mémoire du fichier est absente.'); } final values = {}; for (final entry in rawPreferences.entries) { final key = '${entry.key}'; if (!_isMemoryKey(key)) { // Défense supplémentaire : même un fichier modifié manuellement ne // peut pas injecter une préférence sensible hors de la liste blanche. continue; } final rawValue = entry.value; if (rawValue is! Map) { continue; } values[key] = _decodePreference( rawValue.map((key, value) => MapEntry('$key', value)), ); } DateTime? exportedAt; final rawExportedAt = map['exportedAtUtc']; if (rawExportedAt is String) { exportedAt = DateTime.tryParse(rawExportedAt)?.toUtc(); } return MemoryImportCandidate( sourcePlatform: map['sourcePlatform'] is String ? map['sourcePlatform'] as String : 'inconnu', sourceDeviceId: map['sourceDeviceId'] is String ? map['sourceDeviceId'] as String : 'inconnu', exportedAtUtc: exportedAt, values: values, ); } Future importMemory( MemoryImportCandidate candidate, { required MemoryImportMode mode, }) async { int removed = 0; if (mode == MemoryImportMode.replace) { final current = await _preferences.getAll(); for (final key in current.keys.where(_isMemoryKey)) { await _preferences.remove(key); removed++; } } int imported = 0; for (final entry in candidate.values.entries) { if (!_isMemoryKey(entry.key)) { continue; } await _writePreference(entry.key, entry.value); imported++; } await _preferences.setString( _lastImportKey, DateTime.now().toUtc().toIso8601String(), ); return MemoryImportResult( importedCount: imported, removedCount: removed, mode: mode, ); } bool _isMemoryKey(String key) { if (_allowedExactKeys.contains(key)) { return true; } return _allowedPrefixes.any(key.startsWith); } Map _encodePreference(Object? value) { if (value is bool) { return {'type': 'bool', 'value': value}; } if (value is int) { return {'type': 'int', 'value': value}; } if (value is double) { return {'type': 'double', 'value': value}; } if (value is String) { return {'type': 'string', 'value': value}; } if (value is List) { return {'type': 'stringList', 'value': value}; } if (value is List && value.every((item) => item is String)) { return { 'type': 'stringList', 'value': value.cast(), }; } throw FormatException( 'Type de préférence non pris en charge : ${value.runtimeType}', ); } Object? _decodePreference(Map encoded) { final type = encoded['type']; final value = encoded['value']; switch (type) { case 'bool': if (value is bool) return value; break; case 'int': if (value is num) return value.toInt(); break; case 'double': if (value is num) return value.toDouble(); break; case 'string': if (value is String) return value; break; case 'stringList': if (value is List && value.every((item) => item is String)) { return value.cast(); } break; } throw FormatException('Valeur mémoire invalide pour le type $type.'); } Future _writePreference(String key, Object? value) async { if (value is bool) { await _preferences.setBool(key, value); return; } if (value is int) { await _preferences.setInt(key, value); return; } if (value is double) { await _preferences.setDouble(key, value); return; } if (value is String) { await _preferences.setString(key, value); return; } if (value is List) { await _preferences.setStringList(key, value); return; } throw FormatException( 'Type mémoire impossible à importer : ${value.runtimeType}', ); } Future _getOrCreateDeviceId() async { final existing = await _preferences.getString(_deviceIdKey); if (existing != null && existing.trim().isNotEmpty) { return existing; } final random = Random.secure(); final bytes = List.generate(12, (_) => random.nextInt(256)); final randomPart = bytes .map((value) => value.toRadixString(16).padLeft(2, '0')) .join(); final id = '${Platform.operatingSystem}-$randomPart'; await _preferences.setString(_deviceIdKey, id); return id; } String _fileTimestamp(DateTime value) { String two(int number) => number.toString().padLeft(2, '0'); return '${value.year}-${two(value.month)}-${two(value.day)}_' '${two(value.hour)}-${two(value.minute)}-${two(value.second)}Z'; } }