mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-09 22:18:45 +02:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user