From 9665de47a95275227c89c1facd1bddbc24361960 Mon Sep 17 00:00:00 2001 From: Anand kumar Date: Thu, 22 Aug 2024 19:25:34 +0530 Subject: [PATCH] Enabled backup and restore feature for Android #90 #250 --- lib/ui/screens/Settings/settings_screen.dart | 50 ++- .../Settings/settings_screen_controller.dart | 9 + lib/ui/widgets/backup_dialog.dart | 292 +++++++++++++----- lib/ui/widgets/restore_dialog.dart | 128 +++++--- localization/en.json | 16 +- pubspec.yaml | 7 +- 6 files changed, 344 insertions(+), 158 deletions(-) diff --git a/lib/ui/screens/Settings/settings_screen.dart b/lib/ui/screens/Settings/settings_screen.dart index 04733ab2..ee1b6bcc 100644 --- a/lib/ui/screens/Settings/settings_screen.dart +++ b/lib/ui/screens/Settings/settings_screen.dart @@ -449,34 +449,32 @@ class SettingsScreen extends StatelessWidget { onChanged: settingsController.toggleStopPlyabackOnSwipeAway), )), - if (GetPlatform.isWindows) - ListTile( - contentPadding: const EdgeInsets.only(left: 5, right: 10), - title: Text("backupSettingsAndPlaylists".tr), - subtitle: Text( - "backupSettingsAndPlaylistsDes".tr, - style: Theme.of(context).textTheme.bodyMedium, - ), - isThreeLine: true, - onTap: () => showDialog( - context: context, - builder: (context) => const BackupDialog(), - ).whenComplete(() => Get.delete()), + ListTile( + contentPadding: const EdgeInsets.only(left: 5, right: 10), + title: Text("backupSettingsAndPlaylists".tr), + subtitle: Text( + "backupSettingsAndPlaylistsDes".tr, + style: Theme.of(context).textTheme.bodyMedium, ), - if (GetPlatform.isWindows) - ListTile( - contentPadding: const EdgeInsets.only(left: 5, right: 10), - title: Text("restoreSettingsAndPlaylists".tr), - subtitle: Text( - "restoreSettingsAndPlaylistsDes".tr, - style: Theme.of(context).textTheme.bodyMedium, - ), - isThreeLine: true, - onTap: () => showDialog( - context: context, - builder: (context) => const RestoreDialog(), - ).whenComplete(() => Get.delete()), + isThreeLine: true, + onTap: () => showDialog( + context: context, + builder: (context) => const BackupDialog(), + ).whenComplete(() => Get.delete()), + ), + ListTile( + contentPadding: const EdgeInsets.only(left: 5, right: 10), + title: Text("restoreSettingsAndPlaylists".tr), + subtitle: Text( + "restoreSettingsAndPlaylistsDes".tr, + style: Theme.of(context).textTheme.bodyMedium, ), + isThreeLine: true, + onTap: () => showDialog( + context: context, + builder: (context) => const RestoreDialog(), + ).whenComplete(() => Get.delete()), + ), GetPlatform.isAndroid ? Obx( () => ListTile( diff --git a/lib/ui/screens/Settings/settings_screen_controller.dart b/lib/ui/screens/Settings/settings_screen_controller.dart index 78390d8a..c4a6a80c 100644 --- a/lib/ui/screens/Settings/settings_screen_controller.dart +++ b/lib/ui/screens/Settings/settings_screen_controller.dart @@ -269,4 +269,13 @@ class SettingsScreenController extends GetxController { Future closeAllDatabases() async { await Hive.close(); } + + Future get dbDir async { + if (GetPlatform.isDesktop) { + return "$supportDirPath/db"; + } else { + return (await getApplicationDocumentsDirectory()).path; + } + } + } diff --git a/lib/ui/widgets/backup_dialog.dart b/lib/ui/widgets/backup_dialog.dart index 78322d37..7456451a 100644 --- a/lib/ui/widgets/backup_dialog.dart +++ b/lib/ui/widgets/backup_dialog.dart @@ -1,12 +1,17 @@ +import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:harmonymusic/ui/screens/Settings/settings_screen_controller.dart'; -import 'package:harmonymusic/ui/widgets/loader.dart'; +import 'package:hive/hive.dart'; +import '/ui/screens/Settings/settings_screen_controller.dart'; +import '/ui/widgets/loader.dart'; +import '/utils/helper.dart'; import '../../services/permission_service.dart'; import 'common_dialog_widget.dart'; @@ -27,49 +32,62 @@ class BackupDialog extends StatelessWidget { Container( padding: const EdgeInsets.only(bottom: 10.0, top: 10), child: Text( - "backupSettingsAndPlaylists".tr, + "backupAppData".tr, style: Theme.of(context).textTheme.titleMedium, ), ), - SizedBox( - height: 150, - child: Center( - child: Obx(() => backupDialogController.exportProgress - .toInt() == - backupDialogController.filesToExport.length - ? Text("backupMsg".tr) - : backupDialogController.exportRunning.isTrue - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "${backupDialogController.exportProgress.toInt()}/${backupDialogController.filesToExport.length}", - style: - Theme.of(context).textTheme.titleLarge), - const SizedBox( - height: 10, - ), - Text("exporting".tr) - ], - ) - : backupDialogController.ready.isTrue - ? Text( - "${backupDialogController.filesToExport.length} ${"backFilesFound".tr}") - : backupDialogController.scanning.isTrue - ? Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - const LoadingIndicator(), - const SizedBox( - height: 10, - ), - Text("scanning".tr) - ], - ) - : const SizedBox()), + Expanded( + child: SizedBox( + height: 100, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Obx(() => (backupDialogController.scanning.isTrue || + backupDialogController.backupRunning.isTrue) + ? const LoadingIndicator() + : const SizedBox.shrink()), + const SizedBox( + height: 10, + ), + Obx(() => Text(backupDialogController.scanning.isTrue + ? "scanning".tr + : backupDialogController.backupRunning.isTrue + ? "backupInProgress".tr + : backupDialogController.isbackupCompleted.isTrue + ? "backupMsg".tr + : "letsStrart".tr)) + ], + )), ), ), + if (!GetPlatform.isDesktop) + Obx(() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Checkbox( + value: backupDialogController + .isDownloadedfilesSeclected.value, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + onChanged: + backupDialogController.scanning.isTrue || + backupDialogController + .backupRunning.isTrue || + backupDialogController + .isbackupCompleted.isTrue + ? null + : (bool? value) { + backupDialogController + .isDownloadedfilesSeclected + .value = value!; + }, + ), + Text("includeDownloadedFiles".tr), + ]), + )), SizedBox( width: double.maxFinite, child: Align( @@ -79,24 +97,32 @@ class BackupDialog extends StatelessWidget { borderRadius: BorderRadius.circular(10)), child: InkWell( onTap: () { - if (backupDialogController.exportProgress.toInt() == - backupDialogController.filesToExport.length) { + if (backupDialogController.isbackupCompleted.isTrue) { Navigator.of(context).pop(); } else { backupDialogController.backup(); } }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, vertical: 10), - child: Obx( - () => Text( - backupDialogController.exportProgress.toInt() == - backupDialogController.filesToExport.length - ? "close".tr - : "export".tr, - style: - TextStyle(color: Theme.of(context).canvasColor), + child: Obx( + () => Visibility( + visible: + !(backupDialogController.backupRunning.isTrue || + backupDialogController.scanning.isTrue), + replacement: const SizedBox( + height: 40, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15.0, vertical: 10), + child: Obx( + () => Text( + backupDialogController.isbackupCompleted.isTrue + ? "close".tr + : "backup".tr, + style: TextStyle( + color: Theme.of(context).canvasColor), + ), + ), ), ), ), @@ -113,26 +139,30 @@ class BackupDialog extends StatelessWidget { } class BackupDialogController extends GetxController { - final scanning = true.obs; - final ready = false.obs; - final exportRunning = false.obs; - final exportProgress = (-1).obs; + final scanning = false.obs; + final isbackupCompleted = false.obs; + final backupRunning = false.obs; + final isDownloadedfilesSeclected = false.obs; List filesToExport = []; - - @override - void onInit() { - scanFilesToBackup(); - super.onInit(); - } + final supportDirPath = Get.find().supportDirPath; Future scanFilesToBackup() async { - final supportDirPath = Get.find().supportDirPath; - final filesEntityList = - Directory("$supportDirPath/db").listSync(recursive: false); - final filesPath = filesEntityList.map((entity) => entity.path).toList(); - filesToExport.addAll(filesPath); - scanning.value = false; - ready.value = true; + final dbDir = await Get.find().dbDir; + filesToExport.addAll(await processDirectoryInIsolate(dbDir)); + if (isDownloadedfilesSeclected.value) { + List downlodedSongFilePaths = Hive.box("SongDownloads") + .values + .map((data) => data['url']) + .toList(); + filesToExport.addAll(downlodedSongFilePaths); + try { + filesToExport.addAll(await processDirectoryInIsolate( + "$supportDirPath/thumbnails", + extensionFilter: ".png")); + } catch (e) { + printERROR(e); + } + } } Future backup() async { @@ -150,20 +180,118 @@ class BackupDialogController extends GetxController { return; } - exportProgress.value = 0; - exportRunning.value = true; + scanning.value = true; + await Future.delayed(const Duration(seconds: 4)); + await scanFilesToBackup(); + scanning.value = false; + + backupRunning.value = true; final exportDirPath = pickedFolderPath.toString(); - var encoder = ZipFileEncoder(); - encoder.create( - '$exportDirPath/${DateTime.now().millisecondsSinceEpoch.toString()}.hmb'); - final length_ = filesToExport.length; - for (int i = 0; i < length_; i++) { - final filePath = filesToExport[i]; - await encoder.addFile(File(filePath)); - exportProgress.value = i + 1; + compressFilesInBackground(filesToExport, + '$exportDirPath/${DateTime.now().millisecondsSinceEpoch.toString()}.hmb') + .then((_) { + backupRunning.value = false; + isbackupCompleted.value = true; + }).catchError((e) { + printERROR('Error during compression: $e'); + }); + } +} + +// Function to convert file paths to base64-encoded file data +List filePathsToBase64(List filePaths) { + List base64Data = []; + + for (String path in filePaths) { + try { + // Read the file data as bytes + File file = File(path); + List fileData = file.readAsBytesSync(); + // Convert bytes to base64 + String base64String = base64Encode(fileData); + base64Data.add(base64String); + } catch (e) { + printERROR('Error reading file $path: $e'); + } + } + + return base64Data; +} + +// Function to convert file paths to file data (List) +List> filePathsToFileData(List filePaths) { + List> filesData = []; + + for (String path in filePaths) { + try { + // Read the file data as bytes + File file = File(path); + List fileData = file.readAsBytesSync(); + filesData.add(fileData); + } catch (e) { + printERROR('Error reading file $path: $e'); } - encoder.close(); - exportRunning.value = false; } + + return filesData; +} + +// Function to compress files (to be used with compute or isolate) +void _compressFiles(Map params) { + final List> filesData = params['filesData']; + final List fileNames = params['fileNames']; + final String zipFilePath = params['zipFilePath']; + + final archive = Archive(); + + for (int i = 0; i < filesData.length; i++) { + final fileData = filesData[i]; + final fileName = fileNames[i]; + final file = ArchiveFile(fileName, fileData.length, fileData); + archive.addFile(file); + } + + final encoder = ZipEncoder(); + final zipFile = File(zipFilePath); + zipFile.writeAsBytesSync(encoder.encode(archive)!); +} + +// Example usage +Future compressFilesInBackground( + List filePaths, String zipFilePath) async { + // Convert file paths to file data + final List> filesData = filePathsToFileData(filePaths); + final List fileNames = filePaths + .map((path) => path.split(GetPlatform.isWindows ? '\\' : '/').last) + .toList(); + + printINFO(fileNames); + // Use compute to run the compression in the background + await compute(_compressFiles, { + 'filesData': filesData, + 'fileNames': fileNames, + 'zipFilePath': zipFilePath, + }); +} + +Future> processDirectoryInIsolate(String dbDir, + {String extensionFilter = ".hive"}) async { + // Use Isolate.run to execute the function in a new isolate + return await Isolate.run(() async { + // List files in the directory + final filesEntityList = + await Directory(dbDir).list(recursive: false).toList(); + + // Filter out .hive files + final filesPath = filesEntityList + .whereType() // Ensure we only work with files + .map((entity) { + if (entity.path.endsWith(extensionFilter)) return entity.path; + }) + .whereType() + .toList(); + + return filesPath; + }); } diff --git a/lib/ui/widgets/restore_dialog.dart b/lib/ui/widgets/restore_dialog.dart index bc23506c..bfa9a9d4 100644 --- a/lib/ui/widgets/restore_dialog.dart +++ b/lib/ui/widgets/restore_dialog.dart @@ -4,13 +4,14 @@ import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:harmonymusic/ui/screens/Settings/settings_screen_controller.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:restart_app/restart_app.dart'; +import '/ui/screens/Settings/settings_screen_controller.dart'; +import '/utils/helper.dart'; import '../../services/permission_service.dart'; import 'common_dialog_widget.dart'; -import 'package:path/path.dart' as p; - class RestoreDialog extends StatelessWidget { const RestoreDialog({super.key}); @@ -28,7 +29,7 @@ class RestoreDialog extends StatelessWidget { Container( padding: const EdgeInsets.only(bottom: 10.0, top: 10), child: Text( - "restoreSettingsAndPlaylists".tr, + "restoreAppData".tr, style: Theme.of(context).textTheme.titleMedium, ), ), @@ -38,22 +39,28 @@ class RestoreDialog extends StatelessWidget { child: Obx(() => restoreDialogController.restoreProgress .toInt() == restoreDialogController.filesToRestore.toInt() - ? Text("restoreMsg".tr) - : restoreDialogController.restoreRunning.isTrue - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "${restoreDialogController.restoreProgress.toInt()}/${restoreDialogController.filesToRestore.toInt()}", - style: - Theme.of(context).textTheme.titleLarge), - const SizedBox( - height: 10, - ), - Text("restoring".tr) - ], - ) - : const SizedBox()), + ? Text( + "restoreMsg".tr, + textAlign: TextAlign.center, + ) + : restoreDialogController.processingFiles.isTrue + ? Text("processFiles".tr) + : restoreDialogController.restoreRunning.isTrue + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${restoreDialogController.restoreProgress.toInt()}/${restoreDialogController.filesToRestore.toInt()}", + style: Theme.of(context) + .textTheme + .titleLarge), + const SizedBox( + height: 10, + ), + Text("restoring".tr) + ], + ) + : Text("letsStrart".tr)), ), ), SizedBox( @@ -67,23 +74,36 @@ class RestoreDialog extends StatelessWidget { onTap: () { if (restoreDialogController.restoreProgress.toInt() == restoreDialogController.filesToRestore.toInt()) { - exit(0); + GetPlatform.isAndroid + ? Restart.restartApp() + : exit(0); } else { restoreDialogController.backup(); } }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, vertical: 10), - child: Obx( - () => Text( - restoreDialogController.restoreProgress.toInt() == - restoreDialogController.filesToRestore - .toInt() - ? "closeApp".tr - : "restore".tr, - style: - TextStyle(color: Theme.of(context).canvasColor), + child: Obx( + () => Visibility( + visible: restoreDialogController + .processingFiles.isFalse && + restoreDialogController.restoreRunning.isFalse, + replacement: const SizedBox( + height: 40, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15.0, vertical: 10), + child: Obx( + () => Text( + restoreDialogController.restoreProgress + .toInt() == + restoreDialogController.filesToRestore + .toInt() + ? "restartApp".tr + : "restore".tr, + style: TextStyle( + color: Theme.of(context).canvasColor), + ), + ), ), ), ), @@ -103,6 +123,7 @@ class RestoreDialogController extends GetxController { final restoreRunning = false.obs; final restoreProgress = (-1).obs; final filesToRestore = (0).obs; + final processingFiles = false.obs; Future backup() async { if (!await PermissionService.getExtStoragePermission()) { @@ -116,8 +137,8 @@ class RestoreDialogController extends GetxController { final FilePickerResult? pickedFileResult = await FilePicker.platform .pickFiles( dialogTitle: "Select backup file", - type: FileType.custom, - allowedExtensions: ['hmb'], + type: GetPlatform.isWindows ? FileType.custom : FileType.any, + allowedExtensions: GetPlatform.isWindows ? ['hmb'] : null, allowMultiple: false); final String? pickedFile = pickedFileResult?.files.first.path; @@ -126,29 +147,52 @@ class RestoreDialogController extends GetxController { if (pickedFile == '/' || pickedFile == null) { return; } - - restoreProgress.value = 0; - restoreRunning.value = true; + processingFiles.value = true; + await Future.delayed(const Duration(seconds: 4)); final restoreFilePath = pickedFile.toString(); - final dbDirPath = - p.join(Get.find().supportDirPath, "db"); + final supportDirPath = Get.find().supportDirPath; + final dbDirPath = await Get.find().dbDir; final Directory dbDir = Directory(dbDirPath); printInfo(info: dbDir.path); await Get.find().closeAllDatabases(); - await dbDir.delete(recursive: true); + + //delele all the files with extension .hive + for (final file in dbDir.listSync()) { + if (file is File && file.path.endsWith('.hive')) { + await file.delete(); + } + } final bytes = await File(restoreFilePath).readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); filesToRestore.value = archive.length; + restoreProgress.value = 0; + processingFiles.value = false; + restoreRunning.value = true; for (final file in archive) { final filename = file.name; + printINFO(filename); if (file.isFile) { final data = file.content as List; - final outputFile = File('$dbDirPath/$filename'); + final targetFileDir = + filename.endsWith(".m4a") || filename.endsWith(".opus") + ? "$supportDirPath/Music" + : filename.endsWith(".png") + ? "$supportDirPath/thumbnails" + : dbDirPath; + final outputFile = File('$targetFileDir/$filename'); await outputFile.create(recursive: true); await outputFile.writeAsBytes(data); restoreProgress.value++; } } + // Clear file picker temp directory + final tempFilePickerDirPath = + "${(await getApplicationCacheDirectory()).path}/file_picker"; + final tempFilePickerDir = Directory(tempFilePickerDirPath); + if (tempFilePickerDir.existsSync()) { + await tempFilePickerDir.delete(recursive: true); + } + restoreRunning.value = false; } } diff --git a/localization/en.json b/localization/en.json index 8e3a8092..208f0a13 100644 --- a/localization/en.json +++ b/localization/en.json @@ -178,14 +178,20 @@ "resetblacklistedplaylistDes": "Reset all the piped blacklisted playlists", "stopMusicOnTaskClear": "Stop music on task clear", "stopMusicOnTaskClearDes": "Music playback will stop when App being swiped away from the task manager", - "backupSettingsAndPlaylists": "Backup Settings and Playlists", + "backupAppData": "Backup App data", "backupSettingsAndPlaylistsDes": "Saves all settings, playlists and login data in a backup file", - "restoreSettingsAndPlaylists": "Restore Settings and Playlists", + "backup": "Backup", + "letsStrart": "Let's start..", + "processFiles": "Processing files...", + "includeDownloadedFiles": "Include downloded songs files", + "backupInProgress": "Backup in progress...", + "restoreAppData": "Restore App data", "restoreSettingsAndPlaylistsDes": "Restores all settings, login data and playlists from a backup file. Overwrites all current data", "backupMsg": "Backup successfully saved!", "backFilesFound": "databases found", - "restoreMsg": "Successfully restored! Changes are applied on restart", - "restoring": "restoring...", + "restoreMsg": "Successfully restored!\nChanges are applied on restart", + "restoring": "Restoring...", "restore": "Restore", - "closeApp": "Close App" + "closeApp": "Close App", + "restartApp": "Restart App" } diff --git a/pubspec.yaml b/pubspec.yaml index 0dd03acf..10d60d52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,9 +73,9 @@ dependencies: ref: 06de226da469f5c55dd780b215bcc45a0d6269fb sidebar_with_animation: git: - url: https://github.com/anandnet/animated_side_bar.git - branch: main - ref: b53567a42b4ba3793a3cf00d478bdba0ecce33d7 + url: https://github.com/anandnet/animated_side_bar.git + branch: main + ref: b53567a42b4ba3793a3cf00d478bdba0ecce33d7 window_manager: ^0.3.7 smtc_windows: ^0.1.2 audio_service_mpris: ^0.1.5 @@ -84,6 +84,7 @@ dependencies: path: ^1.8.3 tray_manager: ^0.2.3 widget_marquee: ^0.0.8 + restart_app: ^1.2.1 dev_dependencies: flutter_test: sdk: flutter