import 'dart:convert'; import 'dart:io'; 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'; /// Parsed contents of a backup file. class BackupBundle { final int formatVersion; final String appVersion; final DateTime? createdAt; /// Raw `AppSettings.toJson()` map, or null when not present. final Map? settings; /// History items in `DownloadHistoryItem.toJson()` shape. final List> history; /// Collections in `LibraryCollectionsState.toJson()` shape /// (wishlist / loved / playlists / favoriteArtists). final Map collections; /// Playlist cover images keyed by playlist id: `{ id: { ext, data } }`. final Map playlistCovers; /// Extensions section: `{ registry_url, items: [ {id, version, enabled, settings} ] }`. final Map extensions; 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, }); bool get hasSettings => settings != null && settings!.isNotEmpty; int get historyCount => history.length; 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; } /// Builds and parses SpotiFLAC backup files (a single JSON document containing /// settings, download history and the user library). class BackupService { static final _log = AppLogger('BackupService'); static const String magic = 'spotiflac-backup'; static const int formatVersion = 1; static const String fileExtension = 'json'; /// Builds the backup envelope written to disk. static Map buildEnvelope({ required Map? settings, required List> history, required Map collections, required Map playlistCovers, required Map extensions, }) { return { 'magic': magic, 'format_version': formatVersion, 'app': 'SpotiFLAC Mobile', 'app_version': AppInfo.displayVersion, 'created_at': DateTime.now().toIso8601String(), 'data': { 'settings': settings, 'history': history, 'collections': collections, 'playlist_covers': playlistCovers, 'extensions': extensions, }, }; } /// Writes [envelope] to a timestamped file under the app documents directory /// and returns the created file. static Future writeBackupFile(Map envelope) async { final dir = await getApplicationDocumentsDirectory(); final backupsDir = Directory(p.join(dir.path, 'backups')); if (!await backupsDir.exists()) { await backupsDir.create(recursive: true); } final now = DateTime.now(); String two(int v) => v.toString().padLeft(2, '0'); final stamp = '${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}'; final fileName = 'spotiflac_backup_$stamp.$fileExtension'; final file = File(p.join(backupsDir.path, fileName)); await file.writeAsString(jsonEncode(envelope), flush: true); _log.i('Backup written to ${file.path}'); return file; } /// Parses and validates a backup file's contents. Returns null when the /// content is not a recognizable SpotiFLAC backup. 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) { _log.w('Backup parse failed: root is not an object'); return null; } final root = Map.from(decoded); if (root['magic'] != magic) { _log.w('Backup parse failed: magic marker missing'); return null; } final dataRaw = root['data']; if (dataRaw is! Map) { _log.w('Backup parse failed: missing data section'); return null; } final data = Map.from(dataRaw); Map? settings; final settingsRaw = data['settings']; if (settingsRaw is Map) { settings = Map.from(settingsRaw); } final history = >[]; final historyRaw = data['history']; if (historyRaw is List) { for (final item in historyRaw) { if (item is Map) { history.add(Map.from(item)); } } } final collectionsRaw = data['collections']; final collections = collectionsRaw is Map ? Map.from(collectionsRaw) : {}; final coversRaw = data['playlist_covers']; final playlistCovers = coversRaw is Map ? Map.from(coversRaw) : {}; final extensionsRaw = data['extensions']; final extensions = extensionsRaw is Map ? Map.from(extensionsRaw) : {}; 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: settings, history: history, collections: collections, playlistCovers: playlistCovers, extensions: extensions, ); } }