From c571cf2c53a118851f727bb5eae98de0970019c9 Mon Sep 17 00:00:00 2001 From: Boris M Date: Wed, 9 Nov 2022 08:36:04 +0100 Subject: [PATCH] feat: ability to store and load selected patches (#469) * feat: ability to store and load selected patches * fix: I18n * fix: do not append but truncate file * fix: use json file, minor fixes * fix: better ui * WIP * feat: load patches selection after app selection * feat: import/export json file * fix: reformat code * fix: rare bug on import feature fixed * fix: move export/ipmort to settings page & import full json * fix: minor improvements * fix: minor code quality improvements * fix: export filename fix * fix: select list element istead of removing it --- assets/i18n/en_US.json | 13 +++++ lib/services/manager_api.dart | 43 ++++++++++++++ .../app_selector/app_selector_viewmodel.dart | 4 +- lib/ui/views/patcher/patcher_viewmodel.dart | 11 ++++ .../patches_selector_view.dart | 19 +++++- .../patches_selector_viewmodel.dart | 31 ++++++++++ lib/ui/views/settings/settings_view.dart | 48 +++++++++++++++ lib/ui/views/settings/settings_viewmodel.dart | 58 +++++++++++++++++++ 8 files changed, 224 insertions(+), 3 deletions(-) diff --git a/assets/i18n/en_US.json b/assets/i18n/en_US.json index 89718886..094c7461 100644 --- a/assets/i18n/en_US.json +++ b/assets/i18n/en_US.json @@ -77,6 +77,8 @@ "viewTitle": "Select patches", "searchBarHint": "Search patches", "doneButton": "Done", + "loadPatchesSelection": "Load patches selection", + "noSavedPatches": "No saved patches for the selected app\nPress Done to save current selection", "noPatchesFound": "No patches found for the selected app", "selectAllPatchesWarningTitle": "Warning", "selectAllPatchesWarningContent": "You are about to select all patches, that includes unrecommended patches and can cause unwanted behavior." @@ -148,6 +150,17 @@ "deleteTempDirLabel": "Delete temp directory", "deleteTempDirHint": "Delete the temporary directory used to store temporary files", "deletedTempDir": "Temp directory deleted", + "exportPatchesLabel": "Export patches", + "exportPatchesHint": "Export patches to json file", + "exportedPatches": "Patches exported", + "noExportFileFound": "No patches to export", + "importPatchesLabel": "Import patches", + "importPatchesHint": "Import patches from json file", + "importedPatches": "Patches imported", + "resetStoredPatchesLabel": "Reset patches", + "resetStoredPatchesHint": "Reset the stored patches selection", + "resetStoredPatches": "Patches selection has been reset", + "jsonSelectorErrorMessage": "Unable to use selected json file", "deleteLogsLabel": "Delete logs", "deleteLogsHint": "Delete collected manager logs", "deletedLogs": "Logs deleted" diff --git a/lib/services/manager_api.dart b/lib/services/manager_api.dart index f3fb56db..ab0e2154 100644 --- a/lib/services/manager_api.dart +++ b/lib/services/manager_api.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:device_apps/device_apps.dart'; import 'package:injectable/injectable.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:revanced_manager/app/app.locator.dart'; import 'package:revanced_manager/models/patch.dart'; import 'package:revanced_manager/models/patched_application.dart'; @@ -19,6 +20,7 @@ class ManagerAPI { final RootAPI _rootAPI = RootAPI(); final String patcherRepo = 'revanced-patcher'; final String cliRepo = 'revanced-cli'; + late String storedPatchesFile = '/selected-patches.json'; late SharedPreferences _prefs; String defaultApiUrl = 'https://releases.revanced.app/'; String defaultPatcherRepo = 'revanced/revanced-patcher'; @@ -29,6 +31,8 @@ class ManagerAPI { Future initialize() async { _prefs = await SharedPreferences.getInstance(); + storedPatchesFile = + (await getApplicationDocumentsDirectory()).path + storedPatchesFile; } String getApiUrl() { @@ -391,4 +395,43 @@ class ManagerAPI { } return app != null && app.isSplit; } + + Future setSelectedPatches(String app, List patches) async { + final File selectedPatchesFile = File(storedPatchesFile); + Map patchesMap = await readSelectedPatchesFile(); + if (patches.isEmpty) { + patchesMap.remove(app); + } else { + patchesMap[app] = patches; + } + if (selectedPatchesFile.existsSync()) { + selectedPatchesFile.createSync(recursive: true); + } + selectedPatchesFile.writeAsString(jsonEncode(patchesMap)); + } + + Future> getSelectedPatches(String app) async { + Map patchesMap = await readSelectedPatchesFile(); + if (patchesMap.isNotEmpty) { + final List patches = + List.from(patchesMap.putIfAbsent(app, () => List.empty())); + return patches; + } + return List.empty(); + } + + Future> readSelectedPatchesFile() async { + final File selectedPatchesFile = File(storedPatchesFile); + if (selectedPatchesFile.existsSync()) { + String string = selectedPatchesFile.readAsStringSync(); + if (string.trim().isEmpty) return {}; + return json.decode(string); + } + return {}; + } + + Future resetLastSelectedPatches() async { + final File selectedPatchesFile = File(storedPatchesFile); + selectedPatchesFile.deleteSync(); + } } diff --git a/lib/ui/views/app_selector/app_selector_viewmodel.dart b/lib/ui/views/app_selector/app_selector_viewmodel.dart index d5938511..2b4c0286 100644 --- a/lib/ui/views/app_selector/app_selector_viewmodel.dart +++ b/lib/ui/views/app_selector/app_selector_viewmodel.dart @@ -34,7 +34,7 @@ class AppSelectorViewModel extends BaseViewModel { icon: application.icon, patchDate: DateTime.now(), ); - locator().selectedPatches.clear(); + locator().loadLastSelectedPatches(); locator().notifyListeners(); } @@ -66,7 +66,7 @@ class AppSelectorViewModel extends BaseViewModel { patchDate: DateTime.now(), isFromStorage: true, ); - locator().selectedPatches.clear(); + locator().loadLastSelectedPatches(); locator().notifyListeners(); } } diff --git a/lib/ui/views/patcher/patcher_viewmodel.dart b/lib/ui/views/patcher/patcher_viewmodel.dart index 39733b88..f2d7e60e 100644 --- a/lib/ui/views/patcher/patcher_viewmodel.dart +++ b/lib/ui/views/patcher/patcher_viewmodel.dart @@ -107,4 +107,15 @@ class PatcherViewModel extends BaseViewModel { 'appSelectorCard.recommendedVersion', )}: $recommendedVersion'; } + + Future loadLastSelectedPatches() async { + this.selectedPatches.clear(); + List selectedPatches = + await _managerAPI.getSelectedPatches(selectedApp!.originalPackageName); + List patches = + await _patcherAPI.getFilteredPatches(selectedApp!.originalPackageName); + this.selectedPatches + .addAll(patches.where((patch) => selectedPatches.contains(patch.name))); + notifyListeners(); + } } diff --git a/lib/ui/views/patches_selector/patches_selector_view.dart b/lib/ui/views/patches_selector/patches_selector_view.dart index 84ed6b0a..3338da9f 100644 --- a/lib/ui/views/patches_selector/patches_selector_view.dart +++ b/lib/ui/views/patches_selector/patches_selector_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:revanced_manager/ui/views/patches_selector/patches_selector_viewmodel.dart'; import 'package:revanced_manager/ui/widgets/patchesSelectorView/patch_item.dart'; +import 'package:revanced_manager/ui/widgets/shared/custom_popup_menu.dart'; import 'package:revanced_manager/ui/widgets/shared/search_bar.dart'; import 'package:stacked/stacked.dart'; @@ -63,7 +64,7 @@ class _PatchesSelectorViewState extends State { actions: [ Container( height: 2, - margin: const EdgeInsets.only(right: 16, top: 12, bottom: 12), + margin: const EdgeInsets.only(top: 12, bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), decoration: BoxDecoration( @@ -78,6 +79,22 @@ class _PatchesSelectorViewState extends State { ), ), ), + CustomPopupMenu( + onSelected: (value) => { + model.onMenuSelection(value) + }, + children: { + 0: I18nText( + 'patchesSelectorView.loadPatchesSelection', + child: const Text( + '', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + }, + ), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(64.0), diff --git a/lib/ui/views/patches_selector/patches_selector_viewmodel.dart b/lib/ui/views/patches_selector/patches_selector_viewmodel.dart index 4ef4c2bc..90836f2f 100644 --- a/lib/ui/views/patches_selector/patches_selector_viewmodel.dart +++ b/lib/ui/views/patches_selector/patches_selector_viewmodel.dart @@ -5,6 +5,7 @@ import 'package:revanced_manager/models/patch.dart'; import 'package:revanced_manager/models/patched_application.dart'; import 'package:revanced_manager/services/manager_api.dart'; import 'package:revanced_manager/services/patcher_api.dart'; +import 'package:revanced_manager/services/toast.dart'; import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart'; import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart'; import 'package:stacked/stacked.dart'; @@ -76,6 +77,7 @@ class PatchesSelectorViewModel extends BaseViewModel { void selectPatches() { locator().selectedPatches = selectedPatches; + saveSelectedPatches(); locator().notifyListeners(); } @@ -117,4 +119,33 @@ class PatchesSelectorViewModel extends BaseViewModel { pack.name == app.packageName && (pack.versions.isEmpty || pack.versions.contains(app.version))); } + + void onMenuSelection(value) { + switch (value) { + case 0: + loadSelectedPatches(); + break; + } + } + + Future saveSelectedPatches() async { + List selectedPatches = + this.selectedPatches.map((patch) => patch.name).toList(); + await _managerAPI.setSelectedPatches( + locator().selectedApp!.originalPackageName, + selectedPatches); + } + + Future loadSelectedPatches() async { + List selectedPatches = await _managerAPI.getSelectedPatches( + locator().selectedApp!.originalPackageName); + if (selectedPatches.isNotEmpty) { + this.selectedPatches.clear(); + this.selectedPatches.addAll( + patches.where((patch) => selectedPatches.contains(patch.name))); + } else { + locator().showBottom('patchesSelectorView.noSavedPatches'); + } + notifyListeners(); + } } diff --git a/lib/ui/views/settings/settings_view.dart b/lib/ui/views/settings/settings_view.dart index 213040aa..79c0aac7 100644 --- a/lib/ui/views/settings/settings_view.dart +++ b/lib/ui/views/settings/settings_view.dart @@ -185,6 +185,54 @@ class SettingsView extends StatelessWidget { subtitle: I18nText('settingsView.deleteTempDirHint'), onTap: () => model.deleteTempDir(), ), + ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20.0), + title: I18nText( + 'settingsView.exportPatchesLabel', + child: const Text( + '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ), + subtitle: I18nText('settingsView.exportPatchesHint'), + onTap: () => model.exportPatches(), + ), + ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20.0), + title: I18nText( + 'settingsView.importPatchesLabel', + child: const Text( + '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ), + subtitle: I18nText('settingsView.importPatchesHint'), + onTap: () => model.importPatches(), + ), + ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 20.0), + title: I18nText( + 'settingsView.resetStoredPatchesLabel', + child: const Text( + '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + ), + subtitle: I18nText('settingsView.resetStoredPatchesHint'), + onTap: () => model.resetSelectedPatches(), + ), ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20.0), diff --git a/lib/ui/views/settings/settings_viewmodel.dart b/lib/ui/views/settings/settings_viewmodel.dart index 9216d2f3..bcc602d5 100644 --- a/lib/ui/views/settings/settings_viewmodel.dart +++ b/lib/ui/views/settings/settings_viewmodel.dart @@ -1,7 +1,9 @@ // ignore_for_file: use_build_context_synchronously import 'dart:io'; +import 'package:cr_file_saver/file_saver.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dynamic_themes/dynamic_themes.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; @@ -11,8 +13,10 @@ import 'package:revanced_manager/app/app.locator.dart'; import 'package:revanced_manager/app/app.router.dart'; import 'package:revanced_manager/services/manager_api.dart'; import 'package:revanced_manager/services/toast.dart'; +import 'package:revanced_manager/ui/views/patcher/patcher_viewmodel.dart'; import 'package:revanced_manager/ui/widgets/shared/custom_material_button.dart'; import 'package:revanced_manager/ui/widgets/settingsView/custom_text_field.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:share_extend/share_extend.dart'; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; @@ -347,6 +351,60 @@ class SettingsViewModel extends BaseViewModel { notifyListeners(); } + Future exportPatches() async { + try { + File outFile = File(_managerAPI.storedPatchesFile); + if (outFile.existsSync()) { + String dateTime = DateTime.now() + .toString() + .replaceAll(' ', '_') + .split('.').first; + String tempFilePath = '${outFile.path.substring(0, outFile.path.lastIndexOf('/') + 1)}selected_patches_$dateTime.json'; + outFile.copySync(tempFilePath); + await CRFileSaver.saveFileWithDialog(SaveFileDialogParams( + sourceFilePath: tempFilePath, + destinationFileName: '' + )); + File(tempFilePath).delete(); + locator().showBottom('settingsView.exportedPatches'); + } else { + locator().showBottom('settingsView.noExportFileFound'); + } + } on Exception catch (e, s) { + Sentry.captureException(e, stackTrace: s); + } + } + + Future importPatches() async { + try { + FilePickerResult? result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (result != null && result.files.single.path != null) { + File inFile = File(result.files.single.path!); + final File storedPatchesFile = File(_managerAPI.storedPatchesFile); + if (!storedPatchesFile.existsSync()) { + storedPatchesFile.createSync(recursive: true); + } + inFile.copySync(storedPatchesFile.path); + inFile.delete(); + if (locator().selectedApp != null) { + locator().loadLastSelectedPatches(); + } + locator().showBottom('settingsView.importedPatches'); + } + } on Exception catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + locator().showBottom('settingsView.jsonSelectorErrorMessage'); + } + } + + void resetSelectedPatches() { + _managerAPI.resetLastSelectedPatches(); + _toast.showBottom('settingsView.resetStoredPatches'); + } + Future getSdkVersion() async { AndroidDeviceInfo info = await DeviceInfoPlugin().androidInfo; return info.version.sdkInt ?? -1;