diff --git a/assets/translations/br.json b/assets/translations/br.json index 6a70a80..fa734f4 100644 --- a/assets/translations/br.json +++ b/assets/translations/br.json @@ -254,6 +254,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Remover App?", "other": "Remover Apps?" diff --git a/assets/translations/bs.json b/assets/translations/bs.json index 254f11c..a6ca2ab 100644 --- a/assets/translations/bs.json +++ b/assets/translations/bs.json @@ -111,7 +111,7 @@ "dark": "Tamna", "light": "Svijetla", "followSystem": "Pratite sistem", - "obtainium": "Obtainium", + "obtainium": "Obtainium", "materialYou": "Material You", "useBlackTheme": "Koristite čisto crnu tamnu temu", "appSortBy": "Aplikacije sortirane po", @@ -251,7 +251,9 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", - "removeAppQuestion": { + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", + "removeAppQuestion": { "one": "Želite li ukloniti aplikaciju?", "other": "Želite li ukloniti aplikacije?" }, diff --git a/assets/translations/de.json b/assets/translations/de.json index 81c2ea7..d6a3a10 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "App entfernen?", "other": "Apps entfernen?" diff --git a/assets/translations/en.json b/assets/translations/en.json index f286c68..185e1e9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -254,6 +254,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Remove App?", "other": "Remove Apps?" diff --git a/assets/translations/es.json b/assets/translations/es.json index 8893461..aaf5dcd 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "¿Eliminar Aplicación?", "other": "¿Eliminar Aplicaciones?" diff --git a/assets/translations/fa.json b/assets/translations/fa.json index adf44c2..44b363b 100644 --- a/assets/translations/fa.json +++ b/assets/translations/fa.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "برنامه حذف شود؟", "other": "برنامه ها حذف شوند؟" diff --git a/assets/translations/fr.json b/assets/translations/fr.json index cfae07f..0081dbe 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Supprimer l'application ?", "other": "Supprimer les applications ?" diff --git a/assets/translations/hu.json b/assets/translations/hu.json index 1913544..b694f45 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -250,6 +250,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Eltávolítja az alkalmazást?", "other": "Eltávolítja az alkalmazást?" diff --git a/assets/translations/it.json b/assets/translations/it.json index b211e4e..31e4f59 100644 --- a/assets/translations/it.json +++ b/assets/translations/it.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Rimuovere l'app?", "other": "Rimuovere le app?" diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 1181f88..33b3efa 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -252,6 +252,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "アプリを削除しますか?", "other": "アプリを削除しますか?" diff --git a/assets/translations/pl.json b/assets/translations/pl.json index 1b43982..25151f4 100644 --- a/assets/translations/pl.json +++ b/assets/translations/pl.json @@ -257,6 +257,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Usunąć aplikację?", "few": "Usunąć aplikacje?", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 49bc22d..3635f30 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -251,6 +251,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "Удалить приложение?", "other": "Удалить приложения?" diff --git a/assets/translations/zh.json b/assets/translations/zh.json index ac43598..519425c 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -252,6 +252,8 @@ "versionExtractionRegEx": "Version Extraction RegEx", "matchGroupToUse": "Match Group to Use", "highlightTouchTargets": "Highlight less obvious touch targets", + "pickExportDir": "Pick Export Directory", + "autoExportOnUpdateCheckKeepNum": "Auto-export on update check (keep last N auto-exports)", "removeAppQuestion": { "one": "是否删除应用?", "other": "是否删除应用?" diff --git a/lib/app_sources/html.dart b/lib/app_sources/html.dart index a2a25cc..8676af9 100644 --- a/lib/app_sources/html.dart +++ b/lib/app_sources/html.dart @@ -124,10 +124,7 @@ class HTML extends AppSource { additionalValidators: [ (value) { value ??= '1'; - if (int.tryParse(value) == null) { - return tr('invalidInput'); - } - return null; + return intValidator(value); } ]) ] diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 3f4cc30..9eb0ff5 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -74,6 +74,11 @@ class AppsPageState extends State { setState(() { refreshingSince = null; }); + if (settingsProvider.autoExportOnUpdateCheckKeepNum > 0) { + appsProvider.exportApps(isAuto: true).then((value) { + appsProvider.trimAutoExports(); + }); + } }); } diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index e2ebdf9..8bbb09c 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -104,8 +104,12 @@ class _ImportExportPageState extends State { runObtainiumExport() { HapticFeedback.selectionClick(); - appsProvider.exportApps().then((String path) { - showError(tr('exportedTo', args: [path]), context); + appsProvider + .exportApps(pickOnly: settingsProvider.exportDir == null) + .then((String? result) { + if (result != null) { + showError(tr('exportedTo', args: [result]), context); + } }).catchError((e) { showError(e, context); }); @@ -310,7 +314,10 @@ class _ImportExportPageState extends State { importInProgress ? null : runObtainiumExport, - child: Text(tr('obtainiumExport')))), + child: Text(tr( + settingsProvider.exportDir != null + ? 'obtainiumExport' + : 'pickExportDirKeepLastN')))), const SizedBox( width: 16, ), @@ -323,6 +330,48 @@ class _ImportExportPageState extends State { child: Text(tr('obtainiumImport')))) ], ), + if (settingsProvider.exportDir != null) + Column( + children: [ + const SizedBox(height: 16), + GeneratedForm( + items: [ + [ + GeneratedFormTextField( + 'autoExportOnUpdateCheckKeepNum', + label: tr( + 'autoExportOnUpdateCheckKeepNum'), + required: false, + defaultValue: settingsProvider + .autoExportOnUpdateCheckKeepNum + .toString(), + textInputType: const TextInputType + .numberWithOptions(), + additionalValidators: [ + (value) { + value ??= settingsProvider + .autoExportOnUpdateCheckKeepNum + .toString(); + return intValidator(value, + positive: true); + } + ]) + ] + ], + onValueChanges: (value, valid, isBuilding) { + if (valid && !isBuilding) { + if (value[ + 'autoExportOnUpdateCheckKeepNum'] != + null) { + settingsProvider + .autoExportOnUpdateCheckKeepNum = + int.parse(value[ + 'autoExportOnUpdateCheckKeepNum']); + } + } + }), + ], + ), if (importInProgress) const Column( children: [ @@ -399,7 +448,7 @@ class _ImportExportPageState extends State { fontStyle: FontStyle.italic, fontSize: 12)), const SizedBox( height: 8, - ) + ), ], ))) ])); diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 63a061e..4158fb9 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -16,6 +16,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:obtainium/app_sources/html.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; @@ -31,6 +32,7 @@ import 'package:obtainium/providers/source_provider.dart'; import 'package:http/http.dart'; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter_archive/flutter_archive.dart'; +import 'package:shared_storage/shared_storage.dart' as saf; final pm = AndroidPackageManager(); @@ -167,7 +169,8 @@ class AppsProvider with ChangeNotifier { if (cacheDirs?.isNotEmpty ?? false) { APKDir = cacheDirs!.first; } else { - APKDir = Directory('${await settingsProvider.getAppDir()}/apks'); + APKDir = + Directory('${(await getExternalStorageDirectory())!.path}/apks'); if (!APKDir.existsSync()) { APKDir.createSync(); } @@ -676,7 +679,7 @@ class AppsProvider with ChangeNotifier { Future getAppsDir() async { Directory appsDir = - Directory('${await settingsProvider.getAppDir()}/app_data'); + Directory('${(await getExternalStorageDirectory())!.path}/app_data'); if (!appsDir.existsSync()) { appsDir.createSync(); } @@ -1091,32 +1094,58 @@ class AppsProvider with ChangeNotifier { return updateAppIds; } - Future exportApps() async { - if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) { - if (await Permission.storage.isDenied) { - await Permission.storage.request(); + Future exportApps({bool pickOnly = false, isAuto = false}) async { + if (isAuto) { + logs.add('Started auto-export.'); + } + var exportDir = settingsProvider.exportDir; + if (exportDir == null || pickOnly) { + await settingsProvider.pickExportDirKeepLastN(); + exportDir = settingsProvider.exportDir; + } + if (exportDir == null) { + throw ObtainiumError(tr('unexpectedError')); + } + String? returnPath; + if (!pickOnly) { + var result = await saf.createFile(exportDir, + displayName: + '${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}${isAuto ? '-auto' : ''}.json', + mimeType: 'application/json', + content: jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); + if (result == null) { + throw ObtainiumError(tr('unexpectedError')); } - if (await Permission.storage.isDenied) { - throw ObtainiumError(tr('storagePermissionDenied')); + returnPath = + exportDir.pathSegments.join('/').replaceFirst('tree/primary:', ''); + } + return returnPath; + } + + Future trimAutoExports() async { + var exportDir = settingsProvider.exportDir; + if (exportDir != null) { + var files = await saf + .listFiles(exportDir, columns: [saf.DocumentFileColumn.id]).toList(); + var maxCount = settingsProvider.autoExportOnUpdateCheckKeepNum; + if (files.length > maxCount) { + files.sort((a, b) { + if (a.name == null) { + return -1; + } else if (b.name == null) { + return 1; + } else { + return compareAlphaNumeric(a.name!, b.name!); + } + }); + files = files.reversed.toList(); + logs.add( + 'Deleting auto-exports older than ${files[maxCount - 1].uri.pathSegments.last}.'); + files.sublist(maxCount).forEach((f) { + saf.delete(f.uri); + }); } } - Directory? exportDir = Directory('/storage/emulated/0/Download'); - String path = 'Downloads'; // TODO: See if hardcoding this can be avoided - var downloadsAccessible = false; - try { - downloadsAccessible = exportDir.existsSync(); - } catch (e) { - logs.add('Error accessing Downloads (will use fallback): $e'); - } - if (!downloadsAccessible) { - exportDir = Directory(await settingsProvider.getAppDir()); - path = exportDir.path; - } - File export = File( - '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json'); - export.writeAsStringSync( - jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); - return path; } Future importApps(String appsJSON) async { @@ -1402,6 +1431,10 @@ Future bgUpdateCheck(int taskId, Map? params) async { if (toNotify.isNotEmpty) { notificationsProvider.notify(UpdateNotification(toNotify)); } + if (appsProvider.settingsProvider.autoExportOnUpdateCheckKeepNum > 0) { + await appsProvider.exportApps(isAuto: true); + await appsProvider.trimAutoExports(); + } } // If you're done checking and found some silently installable updates, schedule another task which will run in install mode if (didCompleteChecking && toInstall.isNotEmpty) { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index e96b5a7..984d3d9 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,7 +1,6 @@ // Exposes functions used to save/load app settings import 'dart:convert'; -import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -363,52 +362,41 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } - Future getAppDir() async { - return prefs?.getString('appDir') ?? defaultAppDir!; + Uri? get exportDir { + var uriString = prefs?.getString('exportDir'); + if (uriString != null) { + return Uri.parse(uriString); + } else { + return null; + } } - pickAppDir({bool useDefault = false}) async { + Future pickExportDirKeepLastN({bool remove = false}) async { var existingSAFPerms = (await saf.persistedUriPermissions()) ?? []; - var currentAppDir = await getAppDir(); - if (currentAppDir != defaultAppDir) { - currentAppDir = currentAppDir.replaceFirst( - '/storage/emulated/0/', '/tree/primary%3A'); + var currentOneWayDataSyncDir = exportDir; + Uri? newOneWayDataSyncDir; + if (!remove) { + newOneWayDataSyncDir = (await saf.openDocumentTree()); } - String? newAppDir; - if (!useDefault) { - var target = (await saf.openDocumentTree()); - if (target != null) { - newAppDir = target.path - .replaceFirst('/tree/primary%3A', '/storage/emulated/0/'); + if (currentOneWayDataSyncDir?.path != newOneWayDataSyncDir?.path) { + if (newOneWayDataSyncDir == null) { + prefs?.remove('exportDir'); + } else { + prefs?.setString('exportDir', newOneWayDataSyncDir.toString()); } - } else { - newAppDir = defaultAppDir; - } - newAppDir ??= defaultAppDir; - if (currentAppDir != newAppDir) { - moveDirectoryContents(Directory(currentAppDir), Directory(newAppDir!)); - prefs?.setString('appDir', newAppDir); notifyListeners(); } for (var e in existingSAFPerms) { await saf.releasePersistableUriPermission(e.uri); } } -} -void moveDirectoryContents(Directory sourceDir, Directory destinationDir) { - if (!destinationDir.existsSync()) { - destinationDir.createSync(recursive: true); + int get autoExportOnUpdateCheckKeepNum { + return prefs?.getInt('autoExportOnUpdateCheckKeepNum') ?? 0; } - List contents = sourceDir.listSync(); - for (FileSystemEntity entity in contents) { - String newPath = '${destinationDir.path}/${entity.uri.pathSegments.last}'; - if (entity is File) { - entity.renameSync(newPath); - } else if (entity is Directory) { - Directory newDestinationDir = Directory(newPath); - moveDirectoryContents(entity, newDestinationDir); - entity.deleteSync(recursive: true); - } + + set autoExportOnUpdateCheckKeepNum(int val) { + prefs?.setInt('autoExportOnUpdateCheckKeepNum', val); + notifyListeners(); } } diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 3c06be2..43a0f1e 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -521,6 +521,20 @@ regExValidator(String? value) { return null; } +intValidator(String? value, {bool positive = false}) { + if (value == null) { + return tr('invalidInput'); + } + var num = int.tryParse(value); + if (num == null) { + return tr('invalidInput'); + } + if (positive && num <= 0) { + return tr('invalidInput'); + } + return null; +} + class SourceProvider { // Add more source classes here so they are available via the service List get sources => [