377 lines
10 KiB
Dart
377 lines
10 KiB
Dart
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';
|
||
}
|
||
}
|