Files
youtube_personal/lib/services/memory_backup_service.dart

377 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, Object?> 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<String> _allowedExactKeys = <String>{
'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<String> _allowedPrefixes = <String>[
'youtube_personal.radar.',
'youtube_personal.interest_',
];
final SharedPreferencesAsync _preferences = SharedPreferencesAsync();
Future<int> memoryEntryCount() async {
final all = await _preferences.getAll();
return all.keys.where(_isMemoryKey).length;
}
Future<MemoryExportResult?> exportMemory() async {
final all = await _preferences.getAll();
final memory = <String, Object?>{};
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 = <String, Object?>{
'format': formatId,
'schemaVersion': schemaVersion,
'exportedAtUtc': now.toIso8601String(),
'sourcePlatform': Platform.operatingSystem,
'sourceDeviceId': deviceId,
'entryCount': memory.length,
'preferences': <String, Object?>{
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<MemoryImportCandidate?> 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: <String>['json', 'ypmemory'],
);
selected = await openFile(acceptedTypeGroups: <XTypeGroup>[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 = <String, Object?>{};
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<MemoryImportResult> 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<String, Object?> _encodePreference(Object? value) {
if (value is bool) {
return <String, Object?>{'type': 'bool', 'value': value};
}
if (value is int) {
return <String, Object?>{'type': 'int', 'value': value};
}
if (value is double) {
return <String, Object?>{'type': 'double', 'value': value};
}
if (value is String) {
return <String, Object?>{'type': 'string', 'value': value};
}
if (value is List<String>) {
return <String, Object?>{'type': 'stringList', 'value': value};
}
if (value is List && value.every((item) => item is String)) {
return <String, Object?>{
'type': 'stringList',
'value': value.cast<String>(),
};
}
throw FormatException(
'Type de préférence non pris en charge : ${value.runtimeType}',
);
}
Object? _decodePreference(Map<String, dynamic> 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<String>();
}
break;
}
throw FormatException('Valeur mémoire invalide pour le type $type.');
}
Future<void> _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<String>) {
await _preferences.setStringList(key, value);
return;
}
throw FormatException(
'Type mémoire impossible à importer : ${value.runtimeType}',
);
}
Future<String> _getOrCreateDeviceId() async {
final existing = await _preferences.getString(_deviceIdKey);
if (existing != null && existing.trim().isNotEmpty) {
return existing;
}
final random = Random.secure();
final bytes = List<int>.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';
}
}