diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 819a4361..720b1033 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index bd8058e2..c44ecd19 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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", diff --git a/lib/screens/settings/backup_restore_page.dart b/lib/screens/settings/backup_restore_page.dart index 2e8d163a..73de81a2 100644 --- a/lib/screens/settings/backup_restore_page.dart +++ b/lib/screens/settings/backup_restore_page.dart @@ -28,25 +28,43 @@ class _BackupRestorePageState extends ConsumerState { 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 _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 = {}; + var covers = >{}; + 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) + : {}; 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 { .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 { 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 { 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 { 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, diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 45f7947e..94d9f719 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -10,7 +10,7 @@ import 'package:spotiflac_android/utils/logger.dart'; typedef BackupHistoryPageLoader = Future>> 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 collections; final Map playlistCovers; final Map 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> 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 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.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 = {}; 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']), diff --git a/test/backup_restore_picker_test.dart b/test/backup_restore_picker_test.dart index 63cb7160..9ab1cfb8 100644 --- a/test/backup_restore_picker_test.dart +++ b/test/backup_restore_picker_test.dart @@ -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(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(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); diff --git a/test/backup_service_v2_test.dart b/test/backup_service_v2_test.dart index 0738d0c5..7dd02c9f 100644 --- a/test/backup_service_v2_test.dart +++ b/test/backup_service_v2_test.dart @@ -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) => {'id': 'item-$index', 'value': index}, - ); - final offsets = []; + 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) => {'id': 'item-$index', 'value': index}, + ); + final offsets = []; - final file = await BackupService.writeBackupArchive( - settings: const {'theme': 'dark'}, - loadHistoryPage: (limit, offset) async { - offsets.add(offset); - if (offset >= history.length) { - return const >[]; - } - final end = (offset + limit).clamp(0, history.length); - return history.sublist(offset, end); - }, - collections: const { - 'loved': [], - 'wishlist': [], - 'playlists': [], - }, - playlistCoverFiles: { - 'playlist-1': {'ext': '.jpg', 'path': cover.path}, - }, - extensions: const {'items': []}, - 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 >[]; + } + final end = (offset + limit).clamp(0, history.length); + return history.sublist(offset, end); + }, + collections: const { + 'loved': [], + 'wishlist': [], + 'playlists': [], + }, + playlistCoverFiles: { + 'playlist-1': {'ext': '.jpg', 'path': cover.path}, + }, + extensions: const {'items': []}, + 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>()); - final restoredCoverPath = - (restoredCover as Map)['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>()); + final restoredCoverPath = + (restoredCover as Map)['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; + final data = metadata['data'] as Map; + 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, [])); + 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(); + }, + ); }