feat(settings): add backup and restore for settings, history and library

Add a Backup & Restore page that exports app settings, download history, liked tracks, wishlist, playlists (with cover images) and favorite artists into a single JSON file, and restores them on another device. Settings restore preserves device-specific storage location (SAF tree URI, download dir). Includes EN strings and ID translations.
This commit is contained in:
zarzet
2026-06-26 19:54:20 +07:00
parent f236d72a19
commit b2705cb2ae
25 changed files with 3068 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
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<String, dynamic>? settings;
/// History items in `DownloadHistoryItem.toJson()` shape.
final List<Map<String, dynamic>> history;
/// Collections in `LibraryCollectionsState.toJson()` shape
/// (wishlist / loved / playlists / favoriteArtists).
final Map<String, dynamic> collections;
/// Playlist cover images keyed by playlist id: `{ id: { ext, data } }`.
final Map<String, dynamic> playlistCovers;
const BackupBundle({
required this.formatVersion,
required this.appVersion,
required this.createdAt,
required this.settings,
required this.history,
required this.collections,
required this.playlistCovers,
});
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');
bool get isEmpty =>
!hasSettings &&
historyCount == 0 &&
likedCount == 0 &&
wishlistCount == 0 &&
playlistCount == 0 &&
favoriteArtistCount == 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<String, dynamic> buildEnvelope({
required Map<String, dynamic>? settings,
required List<Map<String, dynamic>> history,
required Map<String, dynamic> collections,
required Map<String, dynamic> playlistCovers,
}) {
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,
},
};
}
/// Writes [envelope] to a timestamped file under the app documents directory
/// and returns the created file.
static Future<File> writeBackupFile(Map<String, dynamic> 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<String, dynamic>.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<String, dynamic>.from(dataRaw);
Map<String, dynamic>? settings;
final settingsRaw = data['settings'];
if (settingsRaw is Map) {
settings = Map<String, dynamic>.from(settingsRaw);
}
final history = <Map<String, dynamic>>[];
final historyRaw = data['history'];
if (historyRaw is List) {
for (final item in historyRaw) {
if (item is Map) {
history.add(Map<String, dynamic>.from(item));
}
}
}
final collectionsRaw = data['collections'];
final collections = collectionsRaw is Map
? Map<String, dynamic>.from(collectionsRaw)
: <String, dynamic>{};
final coversRaw = data['playlist_covers'];
final playlistCovers = coversRaw is Map
? Map<String, dynamic>.from(coversRaw)
: <String, dynamic>{};
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,
);
}
}
@@ -595,4 +595,97 @@ class LibraryCollectionsDatabase {
);
});
}
/// Wipes every collection table and rewrites them from a restored backup.
///
/// [collectionsJson] must use the same shape as
/// `LibraryCollectionsState.toJson()` (wishlist/loved/playlists/favoriteArtists).
/// Track entries carry a nested `track` map (stored as `track_json`); favorite
/// artist entries are stored whole as `artist_json`.
Future<void> replaceAllFromBackup(Map<String, dynamic> collectionsJson) async {
final nowIso = DateTime.now().toIso8601String();
List<Map<String, dynamic>> listOf(String key) {
final raw = collectionsJson[key];
if (raw is! List) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map((e) => Map<String, dynamic>.from(e))
.toList(growable: false);
}
final wishlist = listOf('wishlist');
final loved = listOf('loved');
final playlists = listOf('playlists');
final favoriteArtists = listOf('favoriteArtists');
final db = await database;
await db.transaction((txn) async {
await txn.delete(_tablePlaylistTracks);
await txn.delete(_tablePlaylists);
await txn.delete(_tableWishlist);
await txn.delete(_tableLoved);
await txn.delete(_tableFavoriteArtists);
Future<void> insertTrackEntries(
String table,
List<Map<String, dynamic>> entries,
) async {
for (final entry in entries) {
final key = entry['key'] as String?;
final track = entry['track'];
if (key == null || key.isEmpty || track is! Map) continue;
await txn.insert(table, {
'track_key': key,
'track_json': jsonEncode(track),
'added_at': (entry['addedAt'] as String?) ?? nowIso,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
await insertTrackEntries(_tableWishlist, wishlist);
await insertTrackEntries(_tableLoved, loved);
for (final artist in favoriteArtists) {
final key = artist['key'] as String?;
if (key == null || key.isEmpty) continue;
await txn.insert(_tableFavoriteArtists, {
'artist_key': key,
'artist_json': jsonEncode(artist),
'added_at': (artist['addedAt'] as String?) ?? nowIso,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
for (final playlist in playlists) {
final id = playlist['id'] as String?;
if (id == null || id.isEmpty) continue;
final createdAt = (playlist['createdAt'] as String?) ?? nowIso;
final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt;
await txn.insert(_tablePlaylists, {
'id': id,
'name': (playlist['name'] as String?) ?? '',
'cover_image_path': playlist['coverImagePath'] as String?,
'created_at': createdAt,
'updated_at': updatedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
final tracksRaw = playlist['tracks'];
if (tracksRaw is! List) continue;
for (final trackEntry in tracksRaw.whereType<Map<Object?, Object?>>()) {
final entry = Map<String, dynamic>.from(trackEntry);
final key = entry['key'] as String?;
final track = entry['track'];
if (key == null || key.isEmpty || track is! Map) continue;
await txn.insert(_tablePlaylistTracks, {
'playlist_id': id,
'track_key': key,
'track_json': jsonEncode(track),
'added_at': (entry['addedAt'] as String?) ?? nowIso,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
});
_log.i('Restored collections from backup');
}
}