mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
feat(backup): allow selecting categories and settings-only backups
This commit is contained in:
@@ -4044,6 +4044,14 @@
|
||||
"@backupContentsTitle": {
|
||||
"description": "Header above the list summarizing what the backup contains"
|
||||
},
|
||||
"backupSettingsOnly": "Settings only",
|
||||
"backupSelectHistory": "Download history",
|
||||
"backupSelectCollections": "Library collections",
|
||||
"backupSelectCollectionsDescription": "Liked tracks, wishlist, favorite artists, playlists and playlist covers.",
|
||||
"backupSelectExtensions": "Extensions and their settings",
|
||||
"backupSelectedExportDescription": "Save the selected categories to a backup file. Audio files are not included.",
|
||||
"backupSelectAtLeastOne": "Select at least one category to create a backup.",
|
||||
"backupSelectedRestoreMessage": "Only the categories listed below will be restored. Categories not included in this backup will stay unchanged. Restoring can replace existing data and cannot be undone.",
|
||||
"backupContentsSettings": "App settings",
|
||||
"@backupContentsSettings": {
|
||||
"description": "Backup contents row label for settings"
|
||||
|
||||
@@ -5914,6 +5914,14 @@
|
||||
"libraryFilterTitle": "Filters",
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"extensionHooksAvailable": "{count} hook tersedia",
|
||||
"backupSettingsOnly": "Pengaturan saja",
|
||||
"backupSelectHistory": "Riwayat download",
|
||||
"backupSelectCollections": "Koleksi library",
|
||||
"backupSelectCollectionsDescription": "Lagu disukai, wishlist, artis favorit, playlist, dan sampul playlist.",
|
||||
"backupSelectExtensions": "Ekstensi dan pengaturannya",
|
||||
"backupSelectedExportDescription": "Simpan kategori yang dipilih ke file backup. File audio tidak disertakan.",
|
||||
"backupSelectAtLeastOne": "Pilih minimal satu kategori untuk membuat backup.",
|
||||
"backupSelectedRestoreMessage": "Hanya kategori di bawah yang akan dipulihkan. Kategori yang tidak disertakan dalam backup ini tidak akan berubah. Pemulihan dapat mengganti data yang ada dan tidak dapat dibatalkan.",
|
||||
"backupContentsSettings": "App settings",
|
||||
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
||||
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
|
||||
|
||||
@@ -28,25 +28,43 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
||||
bool _isExporting = false;
|
||||
bool _isImporting = false;
|
||||
bool _includeSecrets = false;
|
||||
bool _includeSettings = true;
|
||||
bool _includeHistory = true;
|
||||
bool _includeCollections = true;
|
||||
bool _includeExtensions = true;
|
||||
|
||||
bool get _isBusy => _isExporting || _isImporting;
|
||||
bool get _hasSelection =>
|
||||
_includeSettings ||
|
||||
_includeHistory ||
|
||||
_includeCollections ||
|
||||
_includeExtensions;
|
||||
|
||||
Future<void> _createBackup() async {
|
||||
if (_isBusy) return;
|
||||
if (_isBusy || !_hasSelection) return;
|
||||
setState(() => _isExporting = true);
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final settings = ref.read(settingsProvider).toJson();
|
||||
final collectionsNotifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final collections = await collectionsNotifier.exportCollections();
|
||||
final covers = await collectionsNotifier.exportPlaylistCoverFiles();
|
||||
final extensions = await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.exportBackup(includeSecrets: _includeSecrets);
|
||||
final settings = _includeSettings
|
||||
? ref.read(settingsProvider).toJson()
|
||||
: null;
|
||||
var collections = <String, dynamic>{};
|
||||
var covers = <String, Map<String, String>>{};
|
||||
if (_includeCollections) {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
collections = await notifier.exportCollections();
|
||||
covers = await notifier.exportPlaylistCoverFiles();
|
||||
}
|
||||
final extensions = _includeExtensions
|
||||
? await ref
|
||||
.read(extensionProvider.notifier)
|
||||
.exportBackup(includeSecrets: _includeSecrets)
|
||||
: <String, dynamic>{};
|
||||
|
||||
final file = await BackupService.writeBackupArchive(
|
||||
settings: settings,
|
||||
includeHistory: _includeHistory,
|
||||
loadHistoryPage: (limit, offset) =>
|
||||
HistoryDatabase.instance.getAll(limit: limit, offset: offset),
|
||||
collections: collections,
|
||||
@@ -111,15 +129,19 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
||||
.read(settingsProvider.notifier)
|
||||
.restoreFromBackup(bundle.settings!);
|
||||
}
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.restoreFromBackupStream(bundle.streamHistory());
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.restoreFromBackup(
|
||||
bundle.collections,
|
||||
coverImages: bundle.playlistCovers,
|
||||
);
|
||||
if (bundle.hasHistory) {
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.restoreFromBackupStream(bundle.streamHistory());
|
||||
}
|
||||
if (bundle.hasCollections) {
|
||||
await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.restoreFromBackup(
|
||||
bundle.collections,
|
||||
coverImages: bundle.playlistCovers,
|
||||
);
|
||||
}
|
||||
|
||||
ExtensionRestoreResult? extResult;
|
||||
if (bundle.hasExtensions) {
|
||||
@@ -164,7 +186,7 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.backupRestoreConfirmMessage),
|
||||
Text(l10n.backupSelectedRestoreMessage),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.backupContentsTitle,
|
||||
@@ -178,22 +200,26 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
||||
icon: Icons.settings_outlined,
|
||||
label: l10n.backupContentsSettings,
|
||||
),
|
||||
_ContentRow(
|
||||
icon: Icons.history,
|
||||
label: l10n.backupContentsHistory(bundle.historyCount),
|
||||
),
|
||||
_ContentRow(
|
||||
icon: Icons.favorite_outline,
|
||||
label: l10n.backupContentsLiked(bundle.likedCount),
|
||||
),
|
||||
_ContentRow(
|
||||
icon: Icons.bookmark_outline,
|
||||
label: l10n.backupContentsWishlist(bundle.wishlistCount),
|
||||
),
|
||||
_ContentRow(
|
||||
icon: Icons.queue_music_outlined,
|
||||
label: l10n.backupContentsPlaylists(bundle.playlistCount),
|
||||
),
|
||||
if (bundle.hasHistory)
|
||||
_ContentRow(
|
||||
icon: Icons.history,
|
||||
label: l10n.backupContentsHistory(bundle.historyCount),
|
||||
),
|
||||
if (bundle.hasCollections)
|
||||
_ContentRow(
|
||||
icon: Icons.favorite_outline,
|
||||
label: l10n.backupContentsLiked(bundle.likedCount),
|
||||
),
|
||||
if (bundle.hasCollections)
|
||||
_ContentRow(
|
||||
icon: Icons.bookmark_outline,
|
||||
label: l10n.backupContentsWishlist(bundle.wishlistCount),
|
||||
),
|
||||
if (bundle.hasCollections)
|
||||
_ContentRow(
|
||||
icon: Icons.queue_music_outlined,
|
||||
label: l10n.backupContentsPlaylists(bundle.playlistCount),
|
||||
),
|
||||
if (bundle.favoriteArtistCount > 0)
|
||||
_ContentRow(
|
||||
icon: Icons.person_outline,
|
||||
@@ -235,20 +261,71 @@ class _BackupRestorePageState extends ConsumerState<BackupRestorePage> {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: l10n.backupSettingsOnly,
|
||||
onTap: _isBusy
|
||||
? null
|
||||
: () => setState(() {
|
||||
_includeSettings = true;
|
||||
_includeHistory = false;
|
||||
_includeCollections = false;
|
||||
_includeExtensions = false;
|
||||
_includeSecrets = false;
|
||||
}),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: l10n.backupContentsSettings,
|
||||
value: _includeSettings,
|
||||
onChanged: _isBusy
|
||||
? null
|
||||
: (value) => setState(() => _includeSettings = value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.history,
|
||||
title: l10n.backupSelectHistory,
|
||||
value: _includeHistory,
|
||||
onChanged: _isBusy
|
||||
? null
|
||||
: (value) => setState(() => _includeHistory = value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: l10n.backupSelectCollections,
|
||||
subtitle: l10n.backupSelectCollectionsDescription,
|
||||
value: _includeCollections,
|
||||
onChanged: _isBusy
|
||||
? null
|
||||
: (value) => setState(() => _includeCollections = value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: l10n.backupSelectExtensions,
|
||||
value: _includeExtensions,
|
||||
onChanged: _isBusy
|
||||
? null
|
||||
: (value) => setState(() {
|
||||
_includeExtensions = value;
|
||||
if (!value) _includeSecrets = false;
|
||||
}),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.vpn_key_outlined,
|
||||
title: l10n.backupIncludeSecrets,
|
||||
subtitle: l10n.backupIncludeSecretsDescription,
|
||||
value: _includeSecrets,
|
||||
onChanged: _isBusy
|
||||
onChanged: _isBusy || !_includeExtensions
|
||||
? null
|
||||
: (value) => setState(() => _includeSecrets = value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.ios_share,
|
||||
title: l10n.backupExportButton,
|
||||
subtitle: l10n.backupExportSectionDescription,
|
||||
onTap: _isBusy ? null : _createBackup,
|
||||
subtitle: _hasSelection
|
||||
? l10n.backupSelectedExportDescription
|
||||
: l10n.backupSelectAtLeastOne,
|
||||
onTap: _isBusy || !_hasSelection ? null : _createBackup,
|
||||
trailing: _isExporting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
typedef BackupHistoryPageLoader =
|
||||
Future<List<Map<String, dynamic>>> Function(int limit, int offset);
|
||||
|
||||
/// Parsed contents of a backup file. Version 2 keeps large history and cover
|
||||
/// Parsed contents of a backup file. ZIP backups keep large history and cover
|
||||
/// payloads on disk until restore consumes them.
|
||||
class BackupBundle {
|
||||
final int formatVersion;
|
||||
@@ -21,6 +21,7 @@ class BackupBundle {
|
||||
final Map<String, dynamic> collections;
|
||||
final Map<String, dynamic> playlistCovers;
|
||||
final Map<String, dynamic> extensions;
|
||||
final bool hasHistory;
|
||||
final String? _historyNdjsonPath;
|
||||
final int? _historyCount;
|
||||
final String? _temporaryDirectoryPath;
|
||||
@@ -34,6 +35,7 @@ class BackupBundle {
|
||||
required this.collections,
|
||||
required this.playlistCovers,
|
||||
required this.extensions,
|
||||
this.hasHistory = true,
|
||||
String? historyNdjsonPath,
|
||||
int? historyCount,
|
||||
String? temporaryDirectoryPath,
|
||||
@@ -42,6 +44,7 @@ class BackupBundle {
|
||||
_temporaryDirectoryPath = temporaryDirectoryPath;
|
||||
|
||||
bool get hasSettings => settings != null && settings!.isNotEmpty;
|
||||
bool get hasCollections => collections.isNotEmpty;
|
||||
int get historyCount => _historyCount ?? history.length;
|
||||
|
||||
Stream<Map<String, dynamic>> streamHistory() async* {
|
||||
@@ -97,7 +100,9 @@ class BackupService {
|
||||
static final _log = AppLogger('BackupService');
|
||||
|
||||
static const String magic = 'spotiflac-backup';
|
||||
static const int formatVersion = 2;
|
||||
// V3 permits omitted categories. Older readers reject it instead of treating
|
||||
// an omitted history as an empty history and clearing existing downloads.
|
||||
static const int formatVersion = 3;
|
||||
static const String fileExtension = 'sflb';
|
||||
static const int _historyPageSize = 500;
|
||||
static const int _maxMetadataBytes = 8 << 20;
|
||||
@@ -141,6 +146,7 @@ class BackupService {
|
||||
required Map<String, dynamic> extensions,
|
||||
Directory? outputDirectory,
|
||||
Directory? temporaryDirectory,
|
||||
bool includeHistory = true,
|
||||
}) async {
|
||||
final output = await _newBackupFile(outputDirectory);
|
||||
final tempRoot = temporaryDirectory ?? await getTemporaryDirectory();
|
||||
@@ -160,7 +166,7 @@ class BackupService {
|
||||
var offset = 0;
|
||||
final historySink = historyFile.openWrite();
|
||||
try {
|
||||
while (true) {
|
||||
while (includeHistory) {
|
||||
final page = await loadHistoryPage(_historyPageSize, offset);
|
||||
for (final item in page) {
|
||||
historySink.writeln(jsonEncode(item));
|
||||
@@ -194,10 +200,10 @@ class BackupService {
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'history_count': historyCount,
|
||||
'data': {
|
||||
'settings': settings,
|
||||
'collections': collections,
|
||||
'playlist_covers': coverManifest,
|
||||
'extensions': extensions,
|
||||
'settings': ?settings,
|
||||
if (collections.isNotEmpty) 'collections': collections,
|
||||
if (coverManifest.isNotEmpty) 'playlist_covers': coverManifest,
|
||||
if (extensions.isNotEmpty) 'extensions': extensions,
|
||||
},
|
||||
};
|
||||
await metadataFile.writeAsString(jsonEncode(metadata), flush: true);
|
||||
@@ -205,7 +211,9 @@ class BackupService {
|
||||
if (await partFile.exists()) await partFile.delete();
|
||||
encoder = ZipFileEncoder()..create(partFile.path);
|
||||
await encoder.addFile(metadataFile, 'metadata.json');
|
||||
await encoder.addFile(historyFile, 'history.ndjson');
|
||||
if (includeHistory) {
|
||||
await encoder.addFile(historyFile, 'history.ndjson');
|
||||
}
|
||||
for (final entry in coverManifest.entries) {
|
||||
final sourcePath = playlistCoverFiles[entry.key]?['path'];
|
||||
if (sourcePath == null) continue;
|
||||
@@ -289,14 +297,18 @@ class BackupService {
|
||||
final historyEntry = archive.find('history.ndjson');
|
||||
if (metadataEntry == null ||
|
||||
metadataEntry.size > _maxMetadataBytes ||
|
||||
historyEntry == null ||
|
||||
historyEntry.size > _maxHistoryBytes) {
|
||||
(historyEntry != null && historyEntry.size > _maxHistoryBytes)) {
|
||||
return null;
|
||||
}
|
||||
final rootRaw = jsonDecode(utf8.decode(metadataEntry.content));
|
||||
if (rootRaw is! Map) return null;
|
||||
final root = Map<String, dynamic>.from(rootRaw);
|
||||
if (root['magic'] != magic || root['format_version'] != formatVersion) {
|
||||
final version = root['format_version'];
|
||||
if (root['magic'] != magic ||
|
||||
(version != 2 && version != formatVersion)) {
|
||||
return null;
|
||||
}
|
||||
if (version == 2 && historyEntry == null) {
|
||||
return null;
|
||||
}
|
||||
final dataRaw = root['data'];
|
||||
@@ -310,10 +322,13 @@ class BackupService {
|
||||
'spotiflac_restore_${DateTime.now().microsecondsSinceEpoch}',
|
||||
),
|
||||
).create(recursive: true);
|
||||
final historyPath = p.join(extractionDir.path, 'history.ndjson');
|
||||
final historyOutput = OutputFileStream(historyPath);
|
||||
historyEntry.writeContent(historyOutput);
|
||||
historyOutput.closeSync();
|
||||
String? historyPath;
|
||||
if (historyEntry != null) {
|
||||
historyPath = p.join(extractionDir.path, 'history.ndjson');
|
||||
final historyOutput = OutputFileStream(historyPath);
|
||||
historyEntry.writeContent(historyOutput);
|
||||
historyOutput.closeSync();
|
||||
}
|
||||
|
||||
final restoredCovers = <String, dynamic>{};
|
||||
final coverManifest = data['playlist_covers'];
|
||||
@@ -350,11 +365,12 @@ class BackupService {
|
||||
}
|
||||
|
||||
return BackupBundle(
|
||||
formatVersion: formatVersion,
|
||||
formatVersion: version as int,
|
||||
appVersion: root['app_version'] as String? ?? '',
|
||||
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
|
||||
settings: _mapOrNull(data['settings']),
|
||||
history: const [],
|
||||
hasHistory: historyEntry != null,
|
||||
historyNdjsonPath: historyPath,
|
||||
historyCount: (root['history_count'] as num?)?.toInt() ?? 0,
|
||||
collections: _mapOrEmpty(data['collections']),
|
||||
@@ -399,6 +415,7 @@ class BackupService {
|
||||
createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''),
|
||||
settings: _mapOrNull(data['settings']),
|
||||
history: history,
|
||||
hasHistory: data['history'] is List,
|
||||
collections: _mapOrEmpty(data['collections']),
|
||||
playlistCovers: _mapOrEmpty(data['playlist_covers']),
|
||||
extensions: _mapOrEmpty(data['extensions']),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/screens/settings/backup_restore_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class _BackupFilePicker extends FilePickerPlatform {
|
||||
bool opened = false;
|
||||
@@ -32,6 +33,45 @@ class _BackupFilePicker extends FilePickerPlatform {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'settings-only preset and empty selection update export controls',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: BackupRestorePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Settings only'));
|
||||
await tester.pumpAndSettle();
|
||||
final switches = tester
|
||||
.widgetList<SettingsSwitchItem>(find.byType(SettingsSwitchItem))
|
||||
.toList();
|
||||
expect(switches.map((item) => item.value), [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(switches.last.onChanged, isNull);
|
||||
switches.first.onChanged!(false);
|
||||
await tester.pumpAndSettle();
|
||||
final export = tester
|
||||
.widgetList<SettingsItem>(find.byType(SettingsItem))
|
||||
.firstWhere((item) => item.icon == Icons.ios_share);
|
||||
expect(export.onTap, isNull);
|
||||
expect(
|
||||
export.subtitle,
|
||||
'Select at least one category to create a backup.',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('restore does not exclude backups with an unknown MIME type', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -49,7 +89,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
final restore = find.text('Choose backup file');
|
||||
await tester.ensureVisible(restore);
|
||||
await tester.scrollUntilVisible(restore, 300);
|
||||
await tester.tap(restore);
|
||||
await tester.pumpAndSettle();
|
||||
expect(picker.opened, isTrue);
|
||||
|
||||
@@ -1,74 +1,176 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:spotiflac_android/services/backup_service.dart';
|
||||
|
||||
void main() {
|
||||
test('ZIP v2 pages history and streams cover files during restore', () async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'spotiflac-backup-test-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
});
|
||||
final output = await Directory(p.join(root.path, 'output')).create();
|
||||
final temporary = await Directory(p.join(root.path, 'temporary')).create();
|
||||
final cover = File(p.join(root.path, 'cover.jpg'));
|
||||
await cover.writeAsBytes([0xff, 0xd8, 0xff, 0xd9]);
|
||||
final history = List.generate(
|
||||
1203,
|
||||
(index) => <String, dynamic>{'id': 'item-$index', 'value': index},
|
||||
);
|
||||
final offsets = <int>[];
|
||||
test(
|
||||
'ZIP backup pages history and streams cover files during restore',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'spotiflac-backup-test-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await root.exists()) await root.delete(recursive: true);
|
||||
});
|
||||
final output = await Directory(p.join(root.path, 'output')).create();
|
||||
final temporary = await Directory(
|
||||
p.join(root.path, 'temporary'),
|
||||
).create();
|
||||
final cover = File(p.join(root.path, 'cover.jpg'));
|
||||
await cover.writeAsBytes([0xff, 0xd8, 0xff, 0xd9]);
|
||||
final history = List.generate(
|
||||
1203,
|
||||
(index) => <String, dynamic>{'id': 'item-$index', 'value': index},
|
||||
);
|
||||
final offsets = <int>[];
|
||||
|
||||
final file = await BackupService.writeBackupArchive(
|
||||
settings: const {'theme': 'dark'},
|
||||
loadHistoryPage: (limit, offset) async {
|
||||
offsets.add(offset);
|
||||
if (offset >= history.length) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
final end = (offset + limit).clamp(0, history.length);
|
||||
return history.sublist(offset, end);
|
||||
},
|
||||
collections: const {
|
||||
'loved': <dynamic>[],
|
||||
'wishlist': <dynamic>[],
|
||||
'playlists': <dynamic>[],
|
||||
},
|
||||
playlistCoverFiles: {
|
||||
'playlist-1': {'ext': '.jpg', 'path': cover.path},
|
||||
},
|
||||
extensions: const {'items': <dynamic>[]},
|
||||
outputDirectory: output,
|
||||
temporaryDirectory: temporary,
|
||||
);
|
||||
final file = await BackupService.writeBackupArchive(
|
||||
settings: const {'theme': 'dark'},
|
||||
loadHistoryPage: (limit, offset) async {
|
||||
offsets.add(offset);
|
||||
if (offset >= history.length) {
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
final end = (offset + limit).clamp(0, history.length);
|
||||
return history.sublist(offset, end);
|
||||
},
|
||||
collections: const {
|
||||
'loved': <dynamic>[],
|
||||
'wishlist': <dynamic>[],
|
||||
'playlists': <dynamic>[],
|
||||
},
|
||||
playlistCoverFiles: {
|
||||
'playlist-1': {'ext': '.jpg', 'path': cover.path},
|
||||
},
|
||||
extensions: const {'items': <dynamic>[]},
|
||||
outputDirectory: output,
|
||||
temporaryDirectory: temporary,
|
||||
);
|
||||
|
||||
expect(file.path, endsWith('.${BackupService.fileExtension}'));
|
||||
expect(offsets, [0, 500, 1000]);
|
||||
final bundle = await BackupService.parseFile(
|
||||
file.path,
|
||||
temporaryDirectory: temporary,
|
||||
);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.formatVersion, 2);
|
||||
expect(bundle.historyCount, history.length);
|
||||
final restoredHistory = await bundle.streamHistory().toList();
|
||||
expect(restoredHistory, hasLength(history.length));
|
||||
expect(restoredHistory.last['id'], 'item-1202');
|
||||
final restoredCover = bundle.playlistCovers['playlist-1'];
|
||||
expect(restoredCover, isA<Map<String, dynamic>>());
|
||||
final restoredCoverPath =
|
||||
(restoredCover as Map<String, dynamic>)['path'] as String;
|
||||
expect(await File(restoredCoverPath).readAsBytes(), [
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xd9,
|
||||
]);
|
||||
expect(file.path, endsWith('.${BackupService.fileExtension}'));
|
||||
expect(offsets, [0, 500, 1000]);
|
||||
final bundle = await BackupService.parseFile(
|
||||
file.path,
|
||||
temporaryDirectory: temporary,
|
||||
);
|
||||
expect(bundle, isNotNull);
|
||||
expect(bundle!.formatVersion, 3);
|
||||
expect(bundle.historyCount, history.length);
|
||||
final restoredHistory = await bundle.streamHistory().toList();
|
||||
expect(restoredHistory, hasLength(history.length));
|
||||
expect(restoredHistory.last['id'], 'item-1202');
|
||||
final restoredCover = bundle.playlistCovers['playlist-1'];
|
||||
expect(restoredCover, isA<Map<String, dynamic>>());
|
||||
final restoredCoverPath =
|
||||
(restoredCover as Map<String, dynamic>)['path'] as String;
|
||||
expect(await File(restoredCoverPath).readAsBytes(), [
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xd9,
|
||||
]);
|
||||
|
||||
await bundle.cleanup();
|
||||
expect(await File(restoredCoverPath).exists(), isFalse);
|
||||
await bundle.cleanup();
|
||||
expect(await File(restoredCoverPath).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('selective backups round trip every category combination', () async {
|
||||
final root = await Directory.systemTemp.createTemp('selective-backup-');
|
||||
addTearDown(() => root.delete(recursive: true));
|
||||
for (var mask = 1; mask < 16; mask++) {
|
||||
final settings = mask & 1 != 0;
|
||||
final history = mask & 2 != 0;
|
||||
final collections = mask & 4 != 0;
|
||||
final extensions = mask & 8 != 0;
|
||||
var historyReads = 0;
|
||||
final file = await BackupService.writeBackupArchive(
|
||||
settings: settings ? {'theme': 'dark'} : null,
|
||||
includeHistory: history,
|
||||
loadHistoryPage: (_, _) async {
|
||||
historyReads++;
|
||||
return [
|
||||
{'id': 'download-1'},
|
||||
];
|
||||
},
|
||||
collections: collections
|
||||
? {
|
||||
'loved': ['track-1'],
|
||||
}
|
||||
: {},
|
||||
playlistCoverFiles: {},
|
||||
extensions: extensions
|
||||
? {
|
||||
'items': [
|
||||
{'id': 'example-extension'},
|
||||
],
|
||||
}
|
||||
: {},
|
||||
outputDirectory: root,
|
||||
temporaryDirectory: root,
|
||||
);
|
||||
final archive = ZipDecoder().decodeBytes(await file.readAsBytes());
|
||||
final metadata =
|
||||
jsonDecode(utf8.decode(archive.find('metadata.json')!.content))
|
||||
as Map<String, dynamic>;
|
||||
final data = metadata['data'] as Map<String, dynamic>;
|
||||
expect(data.containsKey('settings'), settings);
|
||||
expect(data.containsKey('collections'), collections);
|
||||
expect(data.containsKey('extensions'), extensions);
|
||||
expect(archive.find('history.ndjson') != null, history);
|
||||
expect(historyReads, history ? 1 : 0);
|
||||
final bundle = (await BackupService.parseFile(
|
||||
file.path,
|
||||
temporaryDirectory: root,
|
||||
))!;
|
||||
expect(bundle.hasSettings, settings);
|
||||
expect(bundle.hasHistory, history);
|
||||
expect(bundle.hasCollections, collections);
|
||||
expect(bundle.hasExtensions, extensions);
|
||||
expect(
|
||||
await bundle.streamHistory().toList(),
|
||||
history
|
||||
? [
|
||||
{'id': 'download-1'},
|
||||
]
|
||||
: isEmpty,
|
||||
);
|
||||
await bundle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'version 2 archives and legacy JSON retain history restore semantics',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp('legacy-backup-');
|
||||
addTearDown(() => root.delete(recursive: true));
|
||||
final envelope = BackupService.buildEnvelope(
|
||||
settings: {'theme': 'light'},
|
||||
history: [],
|
||||
collections: {},
|
||||
playlistCovers: {},
|
||||
extensions: {},
|
||||
);
|
||||
expect(BackupService.parse(jsonEncode(envelope))!.hasHistory, isTrue);
|
||||
envelope['format_version'] = 2;
|
||||
final metadata = utf8.encode(jsonEncode(envelope));
|
||||
final archive = Archive()
|
||||
..addFile(ArchiveFile('metadata.json', metadata.length, metadata))
|
||||
..addFile(ArchiveFile('history.ndjson', 0, <int>[]));
|
||||
final file = File(p.join(root.path, 'old.sflb'));
|
||||
await file.writeAsBytes(ZipEncoder().encode(archive));
|
||||
final bundle = (await BackupService.parseFile(
|
||||
file.path,
|
||||
temporaryDirectory: root,
|
||||
))!;
|
||||
expect(bundle.formatVersion, 2);
|
||||
expect(bundle.hasHistory, isTrue);
|
||||
expect(bundle.hasSettings, isTrue);
|
||||
await bundle.cleanup();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user