import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'package:archive/archive_io.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; typedef BackupHistoryPageLoader = Future>> Function(int limit, int offset); /// 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; final String appVersion; final DateTime? createdAt; final Map? settings; final List> history; final Map collections; final Map playlistCovers; final Map extensions; final bool hasHistory; final String? _historyNdjsonPath; final int? _historyCount; final String? _temporaryDirectoryPath; const BackupBundle({ required this.formatVersion, required this.appVersion, required this.createdAt, required this.settings, required this.history, required this.collections, required this.playlistCovers, required this.extensions, this.hasHistory = true, String? historyNdjsonPath, int? historyCount, String? temporaryDirectoryPath, }) : _historyNdjsonPath = historyNdjsonPath, _historyCount = historyCount, _temporaryDirectoryPath = temporaryDirectoryPath; bool get hasSettings => settings != null && settings!.isNotEmpty; bool get hasCollections => collections.isNotEmpty; int get historyCount => _historyCount ?? history.length; Stream> streamHistory() async* { if (_historyNdjsonPath == null) { yield* Stream.fromIterable(history); return; } await for (final line in File( _historyNdjsonPath, ).openRead().transform(utf8.decoder).transform(const LineSplitter())) { if (line.trim().isEmpty) continue; final decoded = jsonDecode(line); if (decoded is Map) yield Map.from(decoded); } } Future cleanup() async { final path = _temporaryDirectoryPath; if (path == null || path.isEmpty) return; try { final directory = Directory(path); if (await directory.exists()) await directory.delete(recursive: true); } catch (_) {} } int _collectionListCount(String key) { final value = collections[key]; return value is List ? value.length : 0; } int get likedCount => _collectionListCount('loved'); int get wishlistCount => _collectionListCount('wishlist'); int get playlistCount => _collectionListCount('playlists'); int get favoriteArtistCount => _collectionListCount('favoriteArtists'); int get extensionCount { final items = extensions['items']; return items is List ? items.length : 0; } bool get hasExtensions => extensionCount > 0; bool get isEmpty => !hasSettings && historyCount == 0 && likedCount == 0 && wishlistCount == 0 && playlistCount == 0 && favoriteArtistCount == 0 && extensionCount == 0; } class BackupService { static final _log = AppLogger('BackupService'); static const String magic = 'spotiflac-backup'; // 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; static const int _maxHistoryBytes = 512 << 20; static const int _maxCoverBytes = 20 << 20; static const int _maxAllCoversBytes = 256 << 20; static String encode(Map envelope) => const JsonEncoder.withIndent(' ').convert(envelope); /// Legacy JSON envelope retained so backups from earlier releases and unit /// tests remain readable. New backups are written by [writeBackupArchive]. static Map buildEnvelope({ required Map? settings, required List> history, required Map collections, required Map playlistCovers, required Map extensions, }) { return { 'magic': magic, 'format_version': 1, 'app': 'SpotiFLAC Mobile', 'app_version': AppInfo.displayVersion, 'created_at': DateTime.now().toIso8601String(), 'data': { 'settings': settings, 'history': history, 'collections': collections, 'playlist_covers': playlistCovers, 'extensions': extensions, }, }; } static Future writeBackupArchive({ required Map? settings, required BackupHistoryPageLoader loadHistoryPage, required Map collections, required Map> playlistCoverFiles, required Map extensions, Directory? outputDirectory, Directory? temporaryDirectory, bool includeHistory = true, }) async { final output = await _newBackupFile(outputDirectory); final tempRoot = temporaryDirectory ?? await getTemporaryDirectory(); final staging = await Directory( p.join( tempRoot.path, 'spotiflac_backup_${DateTime.now().microsecondsSinceEpoch}', ), ).create(recursive: true); final historyFile = File(p.join(staging.path, 'history.ndjson')); final metadataFile = File(p.join(staging.path, 'metadata.json')); final partFile = File('${output.path}.part'); try { var historyCount = 0; var offset = 0; final historySink = historyFile.openWrite(); try { while (includeHistory) { final page = await loadHistoryPage(_historyPageSize, offset); for (final item in page) { historySink.writeln(jsonEncode(item)); } await historySink.flush(); historyCount += page.length; offset += page.length; if (page.length < _historyPageSize) break; } await historySink.flush(); } finally { await historySink.close(); } final coverManifest = >{}; var coverIndex = 0; for (final entry in playlistCoverFiles.entries) { final sourcePath = entry.value['path'] ?? ''; final source = File(sourcePath); if (sourcePath.isEmpty || !await source.exists()) continue; var ext = (entry.value['ext'] ?? p.extension(sourcePath)).toLowerCase(); if (!RegExp(r'^\.[a-z0-9]{1,8}$').hasMatch(ext)) ext = '.jpg'; final archiveName = 'covers/cover_${coverIndex++}$ext'; coverManifest[entry.key] = {'ext': ext, 'file': archiveName}; } final metadata = { 'magic': magic, 'format_version': formatVersion, 'app': 'SpotiFLAC Mobile', 'app_version': AppInfo.displayVersion, 'created_at': DateTime.now().toIso8601String(), 'history_count': historyCount, 'data': { '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); if (await partFile.exists()) await partFile.delete(); final archiveFiles = <({String path, String name, bool store})>[ (path: metadataFile.path, name: 'metadata.json', store: false), if (includeHistory) (path: historyFile.path, name: 'history.ndjson', store: false), ]; for (final entry in coverManifest.entries) { final sourcePath = playlistCoverFiles[entry.key]?['path']; if (sourcePath == null) continue; archiveFiles.add(( path: sourcePath, name: entry.value['file']!, store: true, )); } await _encodeArchiveInBackground(partFile.path, archiveFiles); if (await output.exists()) await output.delete(); await partFile.rename(output.path); _log.i('Streaming backup written to ${output.path}'); return output; } finally { try { if (await staging.exists()) await staging.delete(recursive: true); } catch (_) {} try { if (await partFile.exists()) await partFile.delete(); } catch (_) {} } } // archive's file-backed encoder still performs compression and writes // synchronously. Pass only paths to the worker; database/plugin callbacks // and the paged history loader stay on their owning isolate. static Future _encodeArchiveInBackground( String outputPath, List<({String path, String name, bool store})> files, ) => Isolate.run(() async { final encoder = ZipFileEncoder()..create(outputPath); try { for (final file in files) { await encoder.addFile( File(file.path), file.name, file.store ? ZipFileEncoder.store : null, ); } } finally { await encoder.close(); } }); static Future _newBackupFile([Directory? outputDirectory]) async { final dir = outputDirectory ?? await getApplicationDocumentsDirectory(); final backupsDir = outputDirectory ?? Directory(p.join(dir.path, 'backups')); await backupsDir.create(recursive: true); final now = DateTime.now(); String two(int value) => value.toString().padLeft(2, '0'); final stamp = '${now.year}${two(now.month)}${two(now.day)}_' '${two(now.hour)}${two(now.minute)}${two(now.second)}'; return File( p.join(backupsDir.path, 'spotiflac_mobile_backup_$stamp.$fileExtension'), ); } /// Legacy JSON writer retained for compatibility with callers outside the /// settings UI. It no longer defines the default backup format. static Future writeBackupFile(Map envelope) async { final output = await _newBackupFile(); await output.writeAsString(encode(envelope), flush: true); return output; } static Future parseFile( String path, { Directory? temporaryDirectory, }) async { final tempRoot = temporaryDirectory ?? await getTemporaryDirectory(); final bundle = await _parseFileInBackground(path, tempRoot.path); if (bundle == null) { _log.w('Backup file could not be read: invalid or unsupported contents'); } return bundle; } static Future _parseFileInBackground( String path, String temporaryPath, ) => Isolate.run(() => _parseFile(path, temporaryPath)); static Future _parseFile( String path, String temporaryPath, ) async { final file = File(path); final header = await file .openRead(0, 4) .fold>([], (bytes, chunk) => bytes..addAll(chunk)); final isZip = header.length == 4 && header[0] == 0x50 && header[1] == 0x4b && header[2] == 0x03 && header[3] == 0x04; return isZip ? _parseArchive(file, temporaryDirectory: Directory(temporaryPath)) : parse(await file.readAsString()); } static Future _parseArchive( File file, { required Directory temporaryDirectory, }) async { InputFileStream? input; Archive? archive; Directory? extractionDir; try { input = InputFileStream(file.path); archive = ZipDecoder().decodeStream(input); final metadataEntry = archive.find('metadata.json'); final historyEntry = archive.find('history.ndjson'); if (metadataEntry == null || metadataEntry.size > _maxMetadataBytes || (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); 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']; if (dataRaw is! Map) return null; final data = Map.from(dataRaw); extractionDir = await Directory( p.join( temporaryDirectory.path, 'spotiflac_restore_${DateTime.now().microsecondsSinceEpoch}', ), ).create(recursive: true); 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']; if (coverManifest is Map) { var index = 0; var extractedCoverBytes = 0; for (final manifestEntry in coverManifest.entries) { if (manifestEntry.value is! Map) continue; final cover = Map.from(manifestEntry.value as Map); final archiveName = cover['file']?.toString() ?? ''; if (!archiveName.startsWith('covers/') || archiveName.contains('..')) { continue; } final archiveEntry = archive.find(archiveName); if (archiveEntry == null || archiveEntry.size > _maxCoverBytes) { continue; } if (extractedCoverBytes + archiveEntry.size > _maxAllCoversBytes) { break; } var ext = cover['ext']?.toString() ?? '.jpg'; if (!RegExp(r'^\.[a-z0-9]{1,8}$').hasMatch(ext)) ext = '.jpg'; final coverPath = p.join(extractionDir.path, 'cover_${index++}$ext'); final output = OutputFileStream(coverPath); archiveEntry.writeContent(output); output.closeSync(); extractedCoverBytes += archiveEntry.size; restoredCovers[manifestEntry.key.toString()] = { 'ext': ext, 'path': coverPath, }; } } return BackupBundle( 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']), playlistCovers: restoredCovers, extensions: _mapOrEmpty(data['extensions']), temporaryDirectoryPath: extractionDir.path, ); } catch (e) { _log.w('Backup archive parse failed: $e'); if (extractionDir != null) { try { await extractionDir.delete(recursive: true); } catch (_) {} } return null; } finally { if (input != null) await input.close(); } } static BackupBundle? parse(String content) { dynamic decoded; try { decoded = jsonDecode(content); } catch (e) { _log.w('Backup parse failed: not valid JSON ($e)'); return null; } if (decoded is! Map) return null; final root = Map.from(decoded); if (root['magic'] != magic || root['data'] is! Map) return null; final data = Map.from(root['data'] as Map); final history = >[]; if (data['history'] is List) { for (final item in data['history'] as List) { if (item is Map) history.add(Map.from(item)); } } return BackupBundle( formatVersion: (root['format_version'] as num?)?.toInt() ?? 1, appVersion: root['app_version'] as String? ?? '', 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']), ); } static Map? _mapOrNull(dynamic value) => value is Map ? Map.from(value) : null; static Map _mapOrEmpty(dynamic value) => value is Map ? Map.from(value) : {}; }