import 'dart:convert'; import 'dart:io'; 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. Version 2 keeps 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 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, String? historyNdjsonPath, int? historyCount, String? temporaryDirectoryPath, }) : _historyNdjsonPath = historyNdjsonPath, _historyCount = historyCount, _temporaryDirectoryPath = temporaryDirectoryPath; bool get hasSettings => settings != null && settings!.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'; static const int formatVersion = 2; 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, }) 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'); ZipFileEncoder? encoder; try { var historyCount = 0; var offset = 0; final historySink = historyFile.openWrite(); try { while (true) { final page = await loadHistoryPage(_historyPageSize, offset); for (final item in page) { historySink.writeln(jsonEncode(item)); } 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, 'collections': collections, 'playlist_covers': coverManifest, 'extensions': extensions, }, }; await metadataFile.writeAsString(jsonEncode(metadata), flush: true); if (await partFile.exists()) await partFile.delete(); encoder = ZipFileEncoder()..create(partFile.path); await encoder.addFile(metadataFile, 'metadata.json'); await encoder.addFile(historyFile, 'history.ndjson'); for (final entry in coverManifest.entries) { final sourcePath = playlistCoverFiles[entry.key]?['path']; if (sourcePath == null) continue; await encoder.addFile( File(sourcePath), entry.value['file'], ZipFileEncoder.store, ); } await encoder.close(); encoder = null; if (await output.exists()) await output.delete(); await partFile.rename(output.path); _log.i('Streaming backup written to ${output.path}'); return output; } finally { if (encoder != null) { try { await encoder.close(); } catch (_) {} } try { if (await staging.exists()) await staging.delete(recursive: true); } catch (_) {} } } 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 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: temporaryDirectory) : parse(await file.readAsString()); } static Future _parseArchive( File file, { 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); if (root['magic'] != magic || root['format_version'] != formatVersion) { return null; } final dataRaw = root['data']; if (dataRaw is! Map) return null; final data = Map.from(dataRaw); final tempRoot = temporaryDirectory ?? await getTemporaryDirectory(); extractionDir = await Directory( p.join( tempRoot.path, '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(); 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: formatVersion, appVersion: root['app_version'] as String? ?? '', createdAt: DateTime.tryParse(root['created_at'] as String? ?? ''), settings: _mapOrNull(data['settings']), history: const [], 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, 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) : {}; }