From 97ab723d04537a9a81cab7de7d6a1f00029860c8 Mon Sep 17 00:00:00 2001 From: Imran Remtulla <30463115+ImranR98@users.noreply.github.com> Date: Sat, 5 Nov 2022 23:29:12 -0400 Subject: [PATCH] Cleanup (#98) --- lib/app_sources/apkmirror.dart | 13 +- lib/app_sources/fdroid.dart | 11 +- lib/app_sources/github.dart | 10 +- lib/app_sources/gitlab.dart | 13 +- lib/app_sources/izzyondroid.dart | 9 +- lib/app_sources/mullvad.dart | 7 +- lib/app_sources/signal.dart | 7 +- lib/app_sources/sourceforge.dart | 9 +- lib/custom_errors.dart | 90 ++++++ lib/main.dart | 19 +- lib/mass_app_sources/githubstars.dart | 6 +- lib/pages/add_app.dart | 25 +- lib/pages/app.dart | 18 +- lib/pages/apps.dart | 13 +- lib/pages/home.dart | 1 - lib/pages/import_export.dart | 61 ++-- lib/pages/settings.dart | 316 ++++++++++---------- lib/providers/apps_provider.dart | 406 +++++++++++--------------- lib/providers/source_provider.dart | 21 +- 19 files changed, 505 insertions(+), 550 deletions(-) diff --git a/lib/app_sources/apkmirror.dart b/lib/app_sources/apkmirror.dart index c11eb95..5142c81 100644 --- a/lib/app_sources/apkmirror.dart +++ b/lib/app_sources/apkmirror.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class APKMirror implements AppSource { @@ -12,7 +13,7 @@ class APKMirror implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -57,7 +58,7 @@ class APKMirror implements AppSource { String standardUrl, List additionalData) async { Response res = await get(Uri.parse('$standardUrl/feed')); if (res.statusCode != 200) { - throw couldNotFindReleases; + throw NoReleasesError(); } var nextUrl = parse(res.body) .querySelector('item') @@ -65,14 +66,14 @@ class APKMirror implements AppSource { ?.nextElementSibling ?.innerHtml; if (nextUrl == null) { - throw couldNotFindReleases; + throw NoReleasesError(); } Response res2 = await get(Uri.parse(nextUrl), headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' }); if (res2.statusCode != 200) { - throw couldNotFindReleases; + throw NoReleasesError(); } var html2 = parse(res2.body); var origin = Uri.parse(standardUrl).origin; @@ -85,11 +86,11 @@ class APKMirror implements AppSource { .map((e) => '$origin$e') .toList(); if (apkUrls.isEmpty) { - throw noAPKFound; + throw NoAPKError(); } var version = html2.querySelector('span.active.accent_color')?.innerHtml; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails(version, apkUrls); } diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart index f2e711a..d40a45f 100644 --- a/lib/app_sources/fdroid.dart +++ b/lib/app_sources/fdroid.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class FDroid implements AppSource { @@ -18,7 +19,7 @@ class FDroid implements AppSource { RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); match = standardUrlRegExA.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -36,7 +37,7 @@ class FDroid implements AppSource { if (res.statusCode == 200) { var releases = parse(res.body).querySelectorAll('.package-version'); if (releases.isEmpty) { - throw couldNotFindReleases; + throw NoReleasesError(); } String? latestVersion = releases[0] .querySelector('.package-version-header b') @@ -45,7 +46,7 @@ class FDroid implements AppSource { .sublist(1) .join(' '); if (latestVersion == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } List apkUrls = releases .where((element) => @@ -64,11 +65,11 @@ class FDroid implements AppSource { .where((element) => element.isNotEmpty) .toList(); if (apkUrls.isEmpty) { - throw noAPKFound; + throw NoAPKError(); } return APKDetails(latestVersion, apkUrls); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index 368c94f..323c8ce 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -16,7 +16,7 @@ class GitHub implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -84,14 +84,14 @@ class GitHub implements AppSource { break; } if (targetRelease == null) { - throw couldNotFindReleases; + throw NoReleasesError(); } if ((targetRelease['apkUrls'] as List).isEmpty) { - throw noAPKFound; + throw NoAPKError(); } String? version = targetRelease['tag_name']; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails(version, targetRelease['apkUrls']); } else { @@ -102,7 +102,7 @@ class GitHub implements AppSource { .round()); } - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart index 058da5d..b8b9094 100644 --- a/lib/app_sources/gitlab.dart +++ b/lib/app_sources/gitlab.dart @@ -2,6 +2,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class GitLab implements AppSource { @@ -13,7 +14,7 @@ class GitLab implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -39,7 +40,9 @@ class GitLab implements AppSource { ...getLinksFromParsedHTML( entryContent, RegExp( - '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', + '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { + return '\\${x[0]}'; + })}/uploads/[^/]+/[^/]+\\.apk\$', caseSensitive: false), standardUri.origin), // GitLab releases may contain links to externally hosted APKs @@ -49,18 +52,18 @@ class GitLab implements AppSource { .toList() ]; if (apkUrlList.isEmpty) { - throw noAPKFound; + throw NoAPKError(); } var entryId = entry?.querySelector('id')?.innerHtml; var version = entryId == null ? null : Uri.parse(entryId).pathSegments.last; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails(version, apkUrlList); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/izzyondroid.dart b/lib/app_sources/izzyondroid.dart index 2c75f5c..0e77915 100644 --- a/lib/app_sources/izzyondroid.dart +++ b/lib/app_sources/izzyondroid.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class IzzyOnDroid implements AppSource { @@ -12,7 +13,7 @@ class IzzyOnDroid implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -37,7 +38,7 @@ class IzzyOnDroid implements AppSource { .map((e) => 'https://$host${e.attributes['href'] ?? ''}') .toList(); if (multipleVersionApkUrls.isEmpty) { - throw noAPKFound; + throw NoAPKError(); } var version = parsedHtml .querySelector('#keydata') @@ -50,11 +51,11 @@ class IzzyOnDroid implements AppSource { ?.children[1] .innerHtml; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails(version, [multipleVersionApkUrls[0]]); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/mullvad.dart b/lib/app_sources/mullvad.dart index 71b6144..a402104 100644 --- a/lib/app_sources/mullvad.dart +++ b/lib/app_sources/mullvad.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class Mullvad implements AppSource { @@ -12,7 +13,7 @@ class Mullvad implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -36,12 +37,12 @@ class Mullvad implements AppSource { ?.split('/') .last; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails( version, ['https://mullvad.net/download/app/apk/latest']); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart index 23d8fdd..250a688 100644 --- a/lib/app_sources/signal.dart +++ b/lib/app_sources/signal.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class Signal implements AppSource { @@ -27,15 +28,15 @@ class Signal implements AppSource { var json = jsonDecode(res.body); String? apkUrl = json['url']; if (apkUrl == null) { - throw noAPKFound; + throw NoAPKError(); } String? version = json['versionName']; if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } return APKDetails(version, [apkUrl]); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/app_sources/sourceforge.dart b/lib/app_sources/sourceforge.dart index 17f02f0..7114bf6 100644 --- a/lib/app_sources/sourceforge.dart +++ b/lib/app_sources/sourceforge.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class SourceForge implements AppSource { @@ -12,7 +13,7 @@ class SourceForge implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL(runtimeType.toString()); + throw InvalidURLError(runtimeType.toString()); } return url.substring(0, match.end); } @@ -42,7 +43,7 @@ class SourceForge implements AppSource { String? version = getVersion(allDownloadLinks[0]); if (version == null) { - throw couldNotFindLatestVersion; + throw NoVersionError(); } var apkUrlListAllReleases = allDownloadLinks .where((element) => element.toLowerCase().endsWith('.apk/download')) @@ -52,11 +53,11 @@ class SourceForge implements AppSource { .where((element) => getVersion(element) == version) .toList(); if (apkUrlList.isEmpty) { - throw noAPKFound; + throw NoAPKError(); } return APKDetails(version, apkUrlList); } else { - throw couldNotFindReleases; + throw NoReleasesError(); } } diff --git a/lib/custom_errors.dart b/lib/custom_errors.dart index d0c738e..b47444a 100644 --- a/lib/custom_errors.dart +++ b/lib/custom_errors.dart @@ -1,3 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:obtainium/providers/apps_provider.dart'; + +class ObtainiumError { + late String message; + ObtainiumError(this.message); + @override + String toString() { + return message; + } +} + class RateLimitError { late int remainingMinutes; RateLimitError(this.remainingMinutes); @@ -6,3 +18,81 @@ class RateLimitError { String toString() => 'Too many requests (rate limited) - try again in $remainingMinutes minutes'; } + +class InvalidURLError extends ObtainiumError { + InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL'); +} + +class NoReleasesError extends ObtainiumError { + NoReleasesError() : super('Could not find a suitable release'); +} + +class NoAPKError extends ObtainiumError { + NoAPKError() : super('Could not find a suitable release'); +} + +class NoVersionError extends ObtainiumError { + NoVersionError() : super('Could not determine release version'); +} + +class UnsupportedURLError extends ObtainiumError { + UnsupportedURLError() : super('URL does not match a known source'); +} + +class DowngradeError extends ObtainiumError { + DowngradeError() : super('Cannot install an older version of an App'); +} + +class IDChangedError extends ObtainiumError { + IDChangedError() + : super('Downloaded package ID does not match existing App ID'); +} + +class MultiAppMultiError extends ObtainiumError { + Map> content = {}; + + MultiAppMultiError() : super('Multiple Errors Placeholder'); + + add(String appId, String string) { + var tempIds = content.remove(string); + tempIds ??= []; + tempIds.add(appId); + content.putIfAbsent(string, () => tempIds!); + } + + @override + String toString() { + String finalString = ''; + for (var e in content.keys) { + finalString += '$e: ${content[e].toString()}\n\n'; + } + return finalString; + } +} + +showError(dynamic e, BuildContext context) { + if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } else { + showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + scrollable: true, + title: Text(e is MultiAppMultiError + ? 'Some Errors Occurred' + : 'Unexpected Error'), + content: Text(e.toString()), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(null); + }, + child: const Text('Ok')), + ], + ); + }); + } +} diff --git a/lib/main.dart b/lib/main.dart index 21a814a..b6aede0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -32,19 +32,16 @@ Future bgUpdateCheck(int taskId, Map? params) async { var notificationsProvider = NotificationsProvider(); await notificationsProvider.notify(checkingUpdatesNotification); try { - var appsProvider = AppsProvider(); + var appsProvider = AppsProvider(forBGTask: true); await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); - await appsProvider.loadApps(shouldCorrectInstallStatus: false); + await appsProvider.loadApps(); List existingUpdateIds = - appsProvider.getExistingUpdates(installedOnly: true); + appsProvider.findExistingUpdates(installedOnly: true); DateTime nextIgnoreAfter = DateTime.now(); String? err; try { await appsProvider.checkUpdates( - ignoreAfter: ignoreAfter, - immediatelyThrowRateLimitError: true, - immediatelyThrowSocketError: true, - shouldCorrectInstallStatus: false); + ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); } catch (e) { if (e is RateLimitError || e is SocketException) { AndroidAlarmManager.oneShot( @@ -59,7 +56,7 @@ Future bgUpdateCheck(int taskId, Map? params) async { } } List newUpdates = appsProvider - .getExistingUpdates(installedOnly: true) + .findExistingUpdates(installedOnly: true) .where((id) => !existingUpdateIds.contains(id)) .map((e) => appsProvider.apps[e]!.app) .toList(); @@ -103,11 +100,7 @@ void main() async { await AndroidAlarmManager.initialize(); runApp(MultiProvider( providers: [ - ChangeNotifierProvider( - create: (context) => AppsProvider( - shouldLoadApps: true, - shouldCheckUpdatesAfterLoad: false, - shouldDeleteAPKs: true)), + ChangeNotifierProvider(create: (context) => AppsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()), Provider(create: (context) => NotificationsProvider()) ], diff --git a/lib/mass_app_sources/githubstars.dart b/lib/mass_app_sources/githubstars.dart index c0bd948..c9b3984 100644 --- a/lib/mass_app_sources/githubstars.dart +++ b/lib/mass_app_sources/githubstars.dart @@ -5,7 +5,7 @@ import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; -class GitHubStars implements MassAppSource { +class GitHubStars implements MassAppUrlSource { @override late String name = 'GitHub Starred Repos'; @@ -28,14 +28,14 @@ class GitHubStars implements MassAppSource { .round()); } - throw 'Unable to find user\'s starred repos'; + throw ObtainiumError('Unable to find user\'s starred repos'); } } @override Future> getUrls(List args) async { if (args.length != requiredArgs.length) { - throw 'Wrong number of arguments provided'; + throw ObtainiumError('Wrong number of arguments provided'); } List urls = []; var page = 1; diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index 2791627..218c479 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; @@ -76,7 +77,7 @@ class _AddAppPageState extends State { : []; validAdditionalData = source != null ? sourceProvider - .doesSourceHaveRequiredAdditionalData( + .ifSourceAppsRequireAdditionalData( source) : true; } @@ -114,21 +115,20 @@ class _AddAppPageState extends State { .getInstallPermission(); // ignore: use_build_context_synchronously var apkUrl = await appsProvider - .selectApkUrl(app, context); + .confirmApkUrl(app, context); if (apkUrl == null) { - throw 'Cancelled'; + throw ObtainiumError('Cancelled'); } app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); var downloadedApk = - await appsProvider.downloadApp( - app, - showOccasionalProgressToast: - true); + await appsProvider + .downloadApp(app); app.id = downloadedApk.appId; if (appsProvider.apps .containsKey(app.id)) { - throw 'App already added'; + throw ObtainiumError( + 'App already added'); } await appsProvider.saveApps([app]); @@ -142,11 +142,7 @@ class _AddAppPageState extends State { AppPage( appId: app.id))); }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text(e.toString())), - ); + showError(e, context); }).whenComplete(() { setState(() { gettingAppInfo = false; @@ -197,9 +193,6 @@ class _AddAppPageState extends State { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - // const SizedBox( - // height: 48, - // ), const Text( 'Supported Sources:', ), diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 960a27d..19fd3bb 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/generated_form_modal.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -25,10 +26,8 @@ class _AppPageState extends State { var appsProvider = context.watch(); var settingsProvider = context.watch(); getUpdate(String id) { - appsProvider.getUpdate(id).catchError((e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + appsProvider.checkUpdate(id).catchError((e) { + showError(e, context); }); } @@ -217,9 +216,8 @@ class _AppPageState extends State { Expanded( child: ElevatedButton( onPressed: (app?.app.installedVersion == null || - appsProvider - .checkAppObjectForUpdate( - app!.app)) && + app?.app.installedVersion != + app?.app.latestVersion) && !appsProvider.areDownloadsRunning() ? () { HapticFeedback.heavyImpact(); @@ -231,11 +229,7 @@ class _AppPageState extends State { Navigator.of(context).pop(); } }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text(e.toString())), - ); + showError(e, context); }); } : null, diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 9731dab..437435e 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; @@ -120,7 +121,7 @@ class AppsPageState extends State { sortedApps = sortedApps.reversed.toList(); } - var existingUpdates = appsProvider.getExistingUpdates(installedOnly: true); + var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdateIdsAllOrSelected = existingUpdates .where((element) => selectedIds.isEmpty @@ -128,7 +129,7 @@ class AppsPageState extends State { : selectedIds.contains(element)) .toList(); var newInstallIdsAllOrSelected = appsProvider - .getExistingUpdates(nonInstalledOnly: true) + .findExistingUpdates(nonInstalledOnly: true) .where((element) => selectedIds.isEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty : selectedIds.contains(element)) @@ -155,9 +156,7 @@ class AppsPageState extends State { refreshingSince = DateTime.now(); }); return appsProvider.checkUpdates().catchError((e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + showError(e, context); }).whenComplete(() { setState(() { refreshingSince = null; @@ -380,9 +379,7 @@ class AppsPageState extends State { .downloadAndInstallLatestApps( toInstall, context) .catchError((e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + showError(e, context); }); }); } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index c1718de..0fdf77a 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -92,7 +92,6 @@ class _HomePageState extends State { return !(pages[0].widget.key as GlobalKey) .currentState ?.clearSelected(); - // return !appsPageKey.currentState?.clearSelected(); }); } } diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index 0cd3a62..48b4e45 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -81,12 +82,8 @@ class _ImportExportPageState extends State { appsProvider .exportApps() .then((String path) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - 'Exported to $path')), - ); + showError( + 'Exported to $path', context); }); }, child: const Text('Obtainium Export'))), @@ -113,27 +110,21 @@ class _ImportExportPageState extends State { try { jsonDecode(data); } catch (e) { - throw 'Invalid input'; + throw ObtainiumError( + 'Invalid input'); } appsProvider .importApps(data) .then((value) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - '$value App${value == 1 ? '' : 's'} Imported')), - ); + showError( + '$value App${value == 1 ? '' : 's'} Imported', + context); }); } else { // User canceled the picker } }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text(e.toString())), - ); + showError(e, context); }).whenComplete(() { setState(() { importInProgress = false; @@ -208,12 +199,9 @@ class _ImportExportPageState extends State { }); addApps(urls).then((errors) { if (errors.isEmpty) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - 'Imported ${urls.length} Apps')), - ); + showError( + 'Imported ${urls.length} Apps', + context); } else { showDialog( context: context, @@ -224,10 +212,7 @@ class _ImportExportPageState extends State { }); } }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar(content: Text(e.toString())), - ); + showError(e, context); }).whenComplete(() { setState(() { importInProgress = false; @@ -239,7 +224,7 @@ class _ImportExportPageState extends State { child: const Text( 'Import from URL List', )), - ...sourceProvider.massSources + ...sourceProvider.massUrlSources .map((source) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -288,13 +273,9 @@ class _ImportExportPageState extends State { .then((errors) { if (errors .isEmpty) { - ScaffoldMessenger - .of(context) - .showSnackBar( - SnackBar( - content: Text( - 'Imported ${selectedUrls.length} Apps')), - ); + showError( + 'Imported ${selectedUrls.length} Apps', + context); } else { showDialog( context: @@ -328,13 +309,7 @@ class _ImportExportPageState extends State { importInProgress = false; }); - ScaffoldMessenger.of( - context) - .showSnackBar( - SnackBar( - content: Text( - e.toString())), - ); + showError(e, context); }); } }); diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index c855657..85521e6 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -21,6 +21,143 @@ class _SettingsPageState extends State { if (settingsProvider.prefs == null) { settingsProvider.initializeSettings(); } + + var themeDropdown = DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Theme'), + value: settingsProvider.theme, + items: const [ + DropdownMenuItem( + value: ThemeSettings.dark, + child: Text('Dark'), + ), + DropdownMenuItem( + value: ThemeSettings.light, + child: Text('Light'), + ), + DropdownMenuItem( + value: ThemeSettings.system, + child: Text('Follow System'), + ) + ], + onChanged: (value) { + if (value != null) { + settingsProvider.theme = value; + } + }); + + var colourDropdown = DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Colour'), + value: settingsProvider.colour, + items: const [ + DropdownMenuItem( + value: ColourSettings.basic, + child: Text('Obtainium'), + ), + DropdownMenuItem( + value: ColourSettings.materialYou, + child: Text('Material You'), + ) + ], + onChanged: (value) { + if (value != null) { + settingsProvider.colour = value; + } + }); + + var sortDropdown = DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'App Sort By'), + value: settingsProvider.sortColumn, + items: const [ + DropdownMenuItem( + value: SortColumnSettings.authorName, + child: Text('Author/Name'), + ), + DropdownMenuItem( + value: SortColumnSettings.nameAuthor, + child: Text('Name/Author'), + ), + DropdownMenuItem( + value: SortColumnSettings.added, + child: Text('As Added'), + ) + ], + onChanged: (value) { + if (value != null) { + settingsProvider.sortColumn = value; + } + }); + + var orderDropdown = DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'App Sort Order'), + value: settingsProvider.sortOrder, + items: const [ + DropdownMenuItem( + value: SortOrderSettings.ascending, + child: Text('Ascending'), + ), + DropdownMenuItem( + value: SortOrderSettings.descending, + child: Text('Descending'), + ), + ], + onChanged: (value) { + if (value != null) { + settingsProvider.sortOrder = value; + } + }); + + var intervalDropdown = DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Background Update Checking Interval'), + value: settingsProvider.updateInterval, + items: updateIntervals.map((e) { + int displayNum = (e < 60 + ? e + : e < 1440 + ? e / 60 + : e / 1440) + .round(); + var displayUnit = (e < 60 + ? 'Minute' + : e < 1440 + ? 'Hour' + : 'Day'); + + String display = e == 0 + ? 'Never - Manual Only' + : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; + return DropdownMenuItem(value: e, child: Text(display)); + }).toList(), + onChanged: (value) { + if (value != null) { + settingsProvider.updateInterval = value; + } + }); + + var sourceSpecificFields = sourceProvider.sources.map((e) { + if (e.moreSourceSettingsFormItems.isNotEmpty) { + return GeneratedForm( + items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), + onValueChanges: (values, valid) { + if (valid) { + for (var i = 0; i < values.length; i++) { + settingsProvider.setSettingString( + e.moreSourceSettingsFormItems[i].id, values[i]); + } + } + }, + defaultValues: e.moreSourceSettingsFormItems.map((e) { + return settingsProvider.getSettingString(e.id) ?? ''; + }).toList()); + } else { + return Container(); + } + }); + + const height16 = SizedBox( + height: 16, + ); + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -38,112 +175,22 @@ class _SettingsPageState extends State { style: TextStyle( color: Theme.of(context).colorScheme.primary), ), - DropdownButtonFormField( - decoration: - const InputDecoration(labelText: 'Theme'), - value: settingsProvider.theme, - items: const [ - DropdownMenuItem( - value: ThemeSettings.dark, - child: Text('Dark'), - ), - DropdownMenuItem( - value: ThemeSettings.light, - child: Text('Light'), - ), - DropdownMenuItem( - value: ThemeSettings.system, - child: Text('Follow System'), - ) - ], - onChanged: (value) { - if (value != null) { - settingsProvider.theme = value; - } - }), - const SizedBox( - height: 16, - ), - DropdownButtonFormField( - decoration: - const InputDecoration(labelText: 'Colour'), - value: settingsProvider.colour, - items: const [ - DropdownMenuItem( - value: ColourSettings.basic, - child: Text('Obtainium'), - ), - DropdownMenuItem( - value: ColourSettings.materialYou, - child: Text('Material You'), - ) - ], - onChanged: (value) { - if (value != null) { - settingsProvider.colour = value; - } - }), - const SizedBox( - height: 16, - ), + themeDropdown, + height16, + colourDropdown, + height16, Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'App Sort By'), - value: settingsProvider.sortColumn, - items: const [ - DropdownMenuItem( - value: - SortColumnSettings.authorName, - child: Text('Author/Name'), - ), - DropdownMenuItem( - value: - SortColumnSettings.nameAuthor, - child: Text('Name/Author'), - ), - DropdownMenuItem( - value: SortColumnSettings.added, - child: Text('As Added'), - ) - ], - onChanged: (value) { - if (value != null) { - settingsProvider.sortColumn = value; - } - })), + Expanded(child: sortDropdown), const SizedBox( width: 16, ), - Expanded( - child: DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'App Sort Order'), - value: settingsProvider.sortOrder, - items: const [ - DropdownMenuItem( - value: SortOrderSettings.ascending, - child: Text('Ascending'), - ), - DropdownMenuItem( - value: SortOrderSettings.descending, - child: Text('Descending'), - ), - ], - onChanged: (value) { - if (value != null) { - settingsProvider.sortOrder = value; - } - })), + Expanded(child: orderDropdown), ], ), - const SizedBox( - height: 16, - ), + height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -155,9 +202,7 @@ class _SettingsPageState extends State { }) ], ), - const SizedBox( - height: 16, - ), + height16, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -172,43 +217,13 @@ class _SettingsPageState extends State { const Divider( height: 16, ), - const SizedBox( - height: 16, - ), + height16, Text( 'Updates', style: TextStyle( color: Theme.of(context).colorScheme.primary), ), - DropdownButtonFormField( - decoration: const InputDecoration( - labelText: - 'Background Update Checking Interval'), - value: settingsProvider.updateInterval, - items: updateIntervals.map((e) { - int displayNum = (e < 60 - ? e - : e < 1440 - ? e / 60 - : e / 1440) - .round(); - var displayUnit = (e < 60 - ? 'Minute' - : e < 1440 - ? 'Hour' - : 'Day'); - - String display = e == 0 - ? 'Never - Manual Only' - : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; - return DropdownMenuItem( - value: e, child: Text(display)); - }).toList(), - onChanged: (value) { - if (value != null) { - settingsProvider.updateInterval = value; - } - }), + intervalDropdown, const Divider( height: 48, ), @@ -217,42 +232,13 @@ class _SettingsPageState extends State { style: TextStyle( color: Theme.of(context).colorScheme.primary), ), - ...sourceProvider.sources.map((e) { - if (e.moreSourceSettingsFormItems.isNotEmpty) { - return GeneratedForm( - items: e.moreSourceSettingsFormItems - .map((e) => [e]) - .toList(), - onValueChanges: (values, valid) { - if (valid) { - for (var i = 0; - i < values.length; - i++) { - settingsProvider.setSettingString( - e.moreSourceSettingsFormItems[i] - .id, - values[i]); - } - } - }, - defaultValues: - e.moreSourceSettingsFormItems.map((e) { - return settingsProvider - .getSettingString(e.id) ?? - ''; - }).toList()); - } else { - return Container(); - } - }), + ...sourceSpecificFields, ], ))), SliverToBoxAdapter( child: Column( children: [ - const SizedBox( - height: 16, - ), + height16, TextButton.icon( style: ButtonStyle( foregroundColor: MaterialStateProperty.resolveWith( @@ -270,9 +256,7 @@ class _SettingsPageState extends State { style: Theme.of(context).textTheme.bodySmall, ), ), - const SizedBox( - height: 16, - ), + height16, ], ), ) diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 245eda6..96c6355 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -25,15 +24,15 @@ import 'package:http/http.dart'; class AppInMemory { late App app; double? downloadProgress; - AppInfo? installedInfo; // Also indicates that an App is installed + AppInfo? installedInfo; AppInMemory(this.app, this.downloadProgress, this.installedInfo); } -class DownloadedApp { +class DownloadedApk { String appId; File file; - DownloadedApp(this.appId, this.file); + DownloadedApk(this.appId, this.file); } class AppsProvider with ChangeNotifier { @@ -41,124 +40,112 @@ class AppsProvider with ChangeNotifier { Map apps = {}; bool loadingApps = false; bool gettingUpdates = false; + bool forBGTask = false; // Variables to keep track of the app foreground status (installs can't run in the background) bool isForeground = true; late Stream? foregroundStream; late StreamSubscription? foregroundSubscription; - AppsProvider( - {bool shouldLoadApps = false, - bool shouldCheckUpdatesAfterLoad = false, - bool shouldDeleteAPKs = false}) { - if (shouldLoadApps) { + AppsProvider({this.forBGTask = false}) { + // Many setup tasks should only be done in the foreground isolate + if (!forBGTask) { // Subscribe to changes in the app foreground status foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundSubscription = foregroundStream?.listen((event) async { isForeground = event == FGBGType.foreground; if (isForeground) await loadApps(); }); - loadApps().then((_) { - if (shouldDeleteAPKs) { - deleteSavedAPKs(); - } - if (shouldCheckUpdatesAfterLoad) { - checkUpdates(); - } - }); + () async { + // Load Apps into memory (in background, this is done later instead of in the constructor) + await loadApps(); + // Delete existing APKs + (await getExternalStorageDirectory()) + ?.listSync() + .where((element) => element.path.endsWith('.apk')) + .forEach((apk) { + apk.delete(); + }); + }(); } } - downloadApk(String apkUrl, String fileName, Function? onProgress, - Function? urlModifier) async { - bool useExistingIfExists = - false; // This should be an function argument, but isn't because of the partially downloaded APK issue + downloadFile(String url, String fileName, Function? onProgress) async { var destDir = (await getExternalStorageDirectory())!.path; - if (urlModifier != null) { - apkUrl = await urlModifier(apkUrl); - } StreamedResponse response = - await Client().send(Request('GET', Uri.parse(apkUrl))); - File downloadFile = File('$destDir/$fileName.apk'); - var alreadyExists = downloadFile.existsSync(); - if (!alreadyExists || !useExistingIfExists) { - if (alreadyExists) { - downloadFile.deleteSync(); - } + await Client().send(Request('GET', Uri.parse(url))); + File downloadedFile = File('$destDir/$fileName'); - var length = response.contentLength; - var received = 0; - double? progress; - var sink = downloadFile.openWrite(); + if (downloadedFile.existsSync()) { + downloadedFile.deleteSync(); + } + var length = response.contentLength; + var received = 0; + double? progress; + var sink = downloadedFile.openWrite(); - await response.stream.map((s) { - received += s.length; - progress = (length != null ? received / length * 100 : 30); - if (onProgress != null) { - onProgress(progress); - } - return s; - }).pipe(sink); - - await sink.close(); - progress = null; + await response.stream.map((s) { + received += s.length; + progress = (length != null ? received / length * 100 : 30); if (onProgress != null) { onProgress(progress); } + return s; + }).pipe(sink); - if (response.statusCode != 200) { - downloadFile.deleteSync(); - throw response.reasonPhrase ?? 'Unknown Error'; - } + await sink.close(); + progress = null; + if (onProgress != null) { + onProgress(progress); } - return downloadFile; + + if (response.statusCode != 200) { + downloadedFile.deleteSync(); + throw response.reasonPhrase ?? 'Unknown Error'; + } + return downloadedFile; } - // Downloads the App (preferred URL) and returns an ApkFile object - // If the app was already saved, updates it's download progress % in memory - // But also works for Apps that are not saved - Future downloadApp(App app, - {bool showOccasionalProgressToast = false}) async { + Future downloadApp(App app) async { + var fileName = + '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; + String downloadUrl = await SourceProvider() + .getSource(app.url) + .apkUrlPrefetchModifier(app.url); int? prevProg; - var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}'; - File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex], - '${app.id}-${app.latestVersion}-${app.preferredApkIndex}', - (double? progress) { + File downloadedFile = + await downloadFile(downloadUrl, fileName, (double? progress) { + int? prog = progress?.ceil(); if (apps[app.id] != null) { apps[app.id]!.downloadProgress = progress; - } - int? prog = progress?.ceil(); - if (showOccasionalProgressToast && - (prog == 25 || prog == 50 || prog == 75) && - prevProg != prog) { + notifyListeners(); + } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) { Fluttertoast.showToast( msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT); } prevProg = prog; - notifyListeners(); - }, SourceProvider().getSource(app.url).apkUrlPrefetchModifier); + }); // Delete older versions of the APK if any - for (var file in downloadFile.parent.listSync()) { + for (var file in downloadedFile.parent.listSync()) { var fn = file.path.split('/').last; if (fn.startsWith('${app.id}-') && fn.endsWith('.apk') && - fn != '$fileName.apk') { + fn != fileName) { file.delete(); } } - // If the ID has changed (as it should on first download), replace it - var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path); + // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed + // The former case should be handled (give the App its real ID), the latter is a security issue + var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path); if (app.id != newInfo.packageName) { - var originalAppId = app.id; - app.id = newInfo.packageName; - downloadFile = downloadFile.renameSync( - '${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); - if (apps[originalAppId] != null) { - await removeApps([originalAppId]); - await saveApps([app]); + if (apps[app.id] != null) { + throw IDChangedError(); } + app.id = newInfo.packageName; + downloadedFile = downloadedFile.renameSync( + '${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); } - return DownloadedApp(app.id, downloadFile); + return DownloadedApk(app.id, downloadedFile); } bool areDownloadsRunning() => apps.values @@ -166,24 +153,26 @@ class AppsProvider with ChangeNotifier { .isNotEmpty; Future canInstallSilently(App app) async { - // TODO: This is unreliable - try to get from OS in the future - var osInfo = await DeviceInfoPlugin().androidInfo; - return app.installedVersion != null && - osInfo.version.sdkInt >= 30 && - osInfo.version.release.compareTo('12') >= 0; + return false; + // TODO: Uncomment the below once silentupdates are ever figured out + // // TODO: This is unreliable - try to get from OS in the future + // if (app.apkUrls.length > 1) { + // return false; + // } + // var osInfo = await DeviceInfoPlugin().androidInfo; + // return app.installedVersion != null && + // osInfo.version.sdkInt >= 30 && + // osInfo.version.release.compareTo('12') >= 0; } - Future askUserToReturnToForeground(BuildContext context, - {bool waitForFG = false}) async { + Future waitForUserToReturnToForeground(BuildContext context) async { NotificationsProvider notificationsProvider = context.read(); if (!isForeground) { await notificationsProvider.notify(completeInstallationNotification, cancelExisting: true); - if (waitForFG) { - await FGBGEvents.stream.first == FGBGType.foreground; - await notificationsProvider.cancel(completeInstallationNotification.id); - } + while (await FGBGEvents.stream.first != FGBGType.foreground) {} + await notificationsProvider.cancel(completeInstallationNotification.id); } } @@ -191,7 +180,7 @@ class AppsProvider with ChangeNotifier { // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing // If appropriate criteria are met, the update (never a fresh install) happens silently in the background // But even then, we don't know if it actually succeeded - Future installApk(DownloadedApp file) async { + Future installApk(DownloadedApk file) async { var newInfo = await PackageArchiveInfo.fromPath(file.file.path); AppInfo? appInfo; try { @@ -201,7 +190,7 @@ class AppsProvider with ChangeNotifier { } if (appInfo != null && int.parse(newInfo.buildNumber) < appInfo.versionCode!) { - throw 'Can\'t install an older version'; + throw DowngradeError(); } if (appInfo == null || int.parse(newInfo.buildNumber) > appInfo.versionCode!) { @@ -210,10 +199,11 @@ class AppsProvider with ChangeNotifier { apps[file.appId]!.app.installedVersion = apps[file.appId]!.app.latestVersion; // Don't correct install status as installation may not be done yet - await saveApps([apps[file.appId]!.app], shouldCorrectInstallStatus: false); + await saveApps([apps[file.appId]!.app], + attemptToCorrectInstallStatus: false); } - Future selectApkUrl(App app, BuildContext? context) async { + Future confirmApkUrl(App app, BuildContext? context) async { // If the App has more than one APK, the user should pick one (if context provided) String? apkUrl = app.apkUrls[app.preferredApkIndex]; if (app.apkUrls.length > 1 && context != null) { @@ -240,15 +230,6 @@ class AppsProvider with ChangeNotifier { return apkUrl; } - Map> addToErrorMap( - Map> errors, String appId, String error) { - var tempIds = errors.remove(error); - tempIds ??= []; - tempIds.add(appId); - errors.putIfAbsent(error, () => tempIds!); - return errors; - } - // Given a list of AppIds, uses stored info about the apps to download APKs and install them // If the APKs can be installed silently, they are // If no BuildContext is provided, apps that require user interaction are ignored @@ -257,42 +238,41 @@ class AppsProvider with ChangeNotifier { Future> downloadAndInstallLatestApps( List appIds, BuildContext? context) async { List appsToInstall = []; + // For all specified Apps, filter out those for which: + // 1. A URL cannot be picked + // 2. That cannot be installed silently (IF no buildContext was given for interactive install) for (var id in appIds) { if (apps[id] == null) { - throw 'App not found'; + throw ObtainiumError('App not found'); } - - String? apkUrl = await selectApkUrl(apps[id]!.app, context); - + String? apkUrl = await confirmApkUrl(apps[id]!.app, context); if (apkUrl != null) { int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); if (urlInd != apps[id]!.app.preferredApkIndex) { apps[id]!.app.preferredApkIndex = urlInd; await saveApps([apps[id]!.app]); } - if (context != null || - (await canInstallSilently(apps[id]!.app) && - apps[id]!.app.apkUrls.length == 1)) { + if (context != null || await canInstallSilently(apps[id]!.app)) { appsToInstall.add(id); } } } - Map> errors = {}; - - List downloadedFiles = + // Download APKs for all Apps to be installed + MultiAppMultiError errors = MultiAppMultiError(); + List downloadedFiles = await Future.wait(appsToInstall.map((id) async { try { return await downloadApp(apps[id]!.app); } catch (e) { - addToErrorMap(errors, id, e.toString()); + errors.add(id, e.toString()); } return null; })); downloadedFiles = downloadedFiles.where((element) => element != null).toList(); - - List silentUpdates = []; - List regularInstalls = []; + // Separate the Apps to install into silent and regular lists + List silentUpdates = []; + List regularInstalls = []; for (var f in downloadedFiles) { bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); if (willBeSilent) { @@ -302,10 +282,13 @@ class AppsProvider with ChangeNotifier { } } + // Move everything to the regular install list (since silent updates don't currently work) - TODO + regularInstalls.addAll(silentUpdates); + // If Obtainium is being installed, it should be the last one - List moveObtainiumToStart(List items) { + List moveObtainiumToStart(List items) { String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; - DownloadedApp? temp; + DownloadedApk? temp; items.removeWhere((element) { bool res = element.appId == obtainiumId; if (res) { @@ -319,37 +302,29 @@ class AppsProvider with ChangeNotifier { return items; } - // TODO: Remove below line if silentupdates are ever figured out - regularInstalls.addAll(silentUpdates); - silentUpdates = moveObtainiumToStart(silentUpdates); regularInstalls = moveObtainiumToStart(regularInstalls); - // TODO: Uncomment below if silentupdates are ever figured out + // // Install silent updates (uncomment when it works - TODO) // for (var u in silentUpdates) { // await installApk(u, silent: true); // Would need to add silent option // } - if (context != null) { - if (regularInstalls.isNotEmpty) { - // ignore: use_build_context_synchronously - await askUserToReturnToForeground(context, waitForFG: true); - } + // Do regular installs + if (regularInstalls.isNotEmpty && context != null) { + // ignore: use_build_context_synchronously + await waitForUserToReturnToForeground(context); for (var i in regularInstalls) { try { await installApk(i); } catch (e) { - addToErrorMap(errors, i.appId, e.toString()); + errors.add(i.appId, e.toString()); } } } - if (errors.isNotEmpty) { - String finalError = ''; - for (var e in errors.keys) { - finalError += - '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; - } - throw finalError; + + if (errors.content.isNotEmpty) { + throw errors; } return downloadedFiles.map((e) => e!.appId).toList(); @@ -364,40 +339,6 @@ class AppsProvider with ChangeNotifier { return appsDir; } - // Delete all stored APKs except those likely to still be needed - Future deleteSavedAPKs() async { - List? apks = (await getExternalStorageDirectory()) - ?.listSync() - .where((element) => element.path.endsWith('.apk')) - .toList(); - if (apks != null && apks.isNotEmpty) { - for (var apk in apks) { - var shouldDelete = true; - var temp = apk.path.split('/').last; - temp = temp.substring(0, temp.length - 4); - var fn = temp.split('-'); - if (fn.length == 3) { - var possibleId = fn[0]; - var possibleVersion = fn[1]; - var possibleApkUrlIndex = fn[2]; - if (apps[possibleId] != null) { - if (apps[possibleId] != null && - apps[possibleId]?.app != null && - apps[possibleId]!.app.installedVersion != - apps[possibleId]!.app.latestVersion && - apps[possibleId]!.app.latestVersion == possibleVersion && - apps[possibleId]!.app.preferredApkIndex.toString() == - possibleApkUrlIndex) { - shouldDelete = false; - } - } - } - - if (shouldDelete) apk.delete(); - } - } - } - Future getInstalledInfo(String? packageName) async { if (packageName != null) { try { @@ -409,24 +350,37 @@ class AppsProvider with ChangeNotifier { return null; } - String standardizeVersionString(String versionString) { - return versionString.characters - .where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'] - .contains(p0)) - .join(''); - } - - // If the App says it is installed by installedInfo is null, set it to not installed + // If the App says it is installed but installedInfo is null, set it to not installed // If the App says is is not installed but installedInfo exists, try to set it to installed as latest version... // ...if the latestVersion seems to match the version in installedInfo (not guaranteed) - App? correctInstallStatus(App app, AppInfo? installedInfo) { + // If that fails, just set it to the actual version string (all we can do at that point) + // Don't save changes, just return the object if changes were made (else null) + // If in a background isolate, return null straight away as the required plugin won't work anyways + App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { + if (forBGTask) { + return null; // Can't correct in the background isolate + } var modded = false; if (installedInfo == null && app.installedVersion != null) { app.installedVersion = null; modded = true; } if (installedInfo != null && app.installedVersion == null) { - if (standardizeVersionString(app.latestVersion) == + if (app.latestVersion.characters + .where((p0) => [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '.' + ].contains(p0)) + .join('') == installedInfo.versionName) { app.installedVersion = app.latestVersion; } else { @@ -437,7 +391,7 @@ class AppsProvider with ChangeNotifier { return modded ? app : null; } - Future loadApps({shouldCorrectInstallStatus = true}) async { + Future loadApps() async { while (loadingApps) { await Future.delayed(const Duration(microseconds: 1)); } @@ -468,28 +422,26 @@ class AppsProvider with ChangeNotifier { } loadingApps = false; notifyListeners(); - // For any that are not installed (by ID == package name), set to not installed if needed - if (shouldCorrectInstallStatus) { - List modifiedApps = []; - for (var app in apps.values) { - var moddedApp = correctInstallStatus(app.app, app.installedInfo); - if (moddedApp != null) { - modifiedApps.add(moddedApp); - } - } - if (modifiedApps.isNotEmpty) { - await saveApps(modifiedApps, shouldCorrectInstallStatus: false); + List modifiedApps = []; + for (var app in apps.values) { + var moddedApp = + getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); + if (moddedApp != null) { + modifiedApps.add(moddedApp); } } + if (modifiedApps.isNotEmpty) { + await saveApps(modifiedApps); + } } Future saveApps(List apps, - {bool shouldCorrectInstallStatus = true}) async { + {bool attemptToCorrectInstallStatus = true}) async { for (var app in apps) { AppInfo? info = await getInstalledInfo(app.id); app.name = info?.name ?? app.name; - if (shouldCorrectInstallStatus) { - app = correctInstallStatus(app, info) ?? app; + if (attemptToCorrectInstallStatus) { + app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app; } File('${(await getAppsDir()).path}/${app.id}.json') .writeAsStringSync(jsonEncode(app.toJson())); @@ -515,15 +467,7 @@ class AppsProvider with ChangeNotifier { } } - bool checkAppObjectForUpdate(App app) { - if (!apps.containsKey(app.id)) { - throw 'App not found'; - } - return app.latestVersion != apps[app.id]?.app.installedVersion; - } - - Future getUpdate(String appId, - {bool shouldCorrectInstallStatus = true}) async { + Future checkUpdate(String appId) async { App? currentApp = apps[appId]!.app; SourceProvider sourceProvider = SourceProvider(); App newApp = await sourceProvider.getApp( @@ -536,51 +480,39 @@ class AppsProvider with ChangeNotifier { if (currentApp.preferredApkIndex < newApp.apkUrls.length) { newApp.preferredApkIndex = currentApp.preferredApkIndex; } - await saveApps([newApp], - shouldCorrectInstallStatus: shouldCorrectInstallStatus); + await saveApps([newApp]); return newApp.latestVersion != currentApp.latestVersion ? newApp : null; } Future> checkUpdates( - {DateTime? ignoreAfter, - bool immediatelyThrowRateLimitError = false, - bool shouldCorrectInstallStatus = true, - bool immediatelyThrowSocketError = false}) async { + {DateTime? ignoreAppsCheckedAfter, + bool throwErrorsForRetry = false}) async { List updates = []; - Map> errors = {}; + MultiAppMultiError errors = MultiAppMultiError(); if (!gettingUpdates) { gettingUpdates = true; - try { - List appIds = apps.keys.toList(); - if (ignoreAfter != null) { - appIds = appIds - .where((id) => - apps[id]!.app.lastUpdateCheck == null || - apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) - .toList(); - } + List appIds = apps.values + .where((app) => + app.app.lastUpdateCheck == null || + ignoreAppsCheckedAfter == null || + app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter)) + .map((e) => e.app.id) + .toList(); appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? DateTime.fromMicrosecondsSinceEpoch(0)) .compareTo(apps[b]!.app.lastUpdateCheck ?? DateTime.fromMicrosecondsSinceEpoch(0))); - for (int i = 0; i < appIds.length; i++) { App? newApp; try { - newApp = await getUpdate(appIds[i], - shouldCorrectInstallStatus: shouldCorrectInstallStatus); + newApp = await checkUpdate(appIds[i]); } catch (e) { - if (e is RateLimitError && immediatelyThrowRateLimitError) { + if ((e is RateLimitError || e is SocketException) && + throwErrorsForRetry) { rethrow; } - if (e is SocketException && immediatelyThrowSocketError) { - rethrow; - } - var tempIds = errors.remove(e.toString()); - tempIds ??= []; - tempIds.add(appIds[i]); - errors.putIfAbsent(e.toString(), () => tempIds!); + errors.add(appIds[i], e.toString()); } if (newApp != null) { updates.add(newApp); @@ -590,18 +522,13 @@ class AppsProvider with ChangeNotifier { gettingUpdates = false; } } - if (errors.isNotEmpty) { - String finalError = ''; - for (var e in errors.keys) { - finalError += - '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; - } - throw finalError; + if (errors.content.isNotEmpty) { + throw errors; } return updates; } - List getExistingUpdates( + List findExistingUpdates( {bool installedOnly = false, bool nonInstalledOnly = false}) { List updateAppIds = []; List appIds = apps.keys.toList(); @@ -635,7 +562,6 @@ class AppsProvider with ChangeNotifier { } Future importApps(String appsJSON) async { - // File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps List importedApps = (jsonDecode(appsJSON) as List) .map((e) => App.fromJson(e)) .toList(); diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 1cf710d..7ad37d1 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -12,6 +12,7 @@ import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/sourceforge.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart'; class AppNames { @@ -90,12 +91,7 @@ class App { }; } -escapeRegEx(String s) { - return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { - return '\\${x[0]}'; - }); -} - +// Ensure the input is starts with HTTPS and has no WWW preStandardizeUrl(String url) { if (url.toLowerCase().indexOf('http://') != 0 && url.toLowerCase().indexOf('https://') != 0) { @@ -145,7 +141,7 @@ abstract class AppSource { Future apkUrlPrefetchModifier(String apkUrl); } -abstract class MassAppSource { +abstract class MassAppUrlSource { late String name; late List requiredArgs; Future> getUrls(List args); @@ -164,8 +160,8 @@ class SourceProvider { // APKMirror() ]; - // Add more mass source classes here so they are available via the service - List massSources = [GitHubStars()]; + // Add more mass url source classes here so they are available via the service + List massUrlSources = [GitHubStars()]; AppSource getSource(String url) { url = preStandardizeUrl(url); @@ -177,12 +173,12 @@ class SourceProvider { } } if (source == null) { - throw 'URL does not match a known source'; + throw UnsupportedURLError(); } return source; } - bool doesSourceHaveRequiredAdditionalData(AppSource source) { + bool ifSourceAppsRequireAdditionalData(AppSource source) { for (var row in source.additionalDataFormItems) { for (var element in row) { if (element.required) { @@ -217,8 +213,7 @@ class SourceProvider { DateTime.now()); } - /// Returns a length 2 list, where the first element is a list of Apps and - /// the second is a Map of URLs and errors + // Returns errors in [results, errors] instead of throwing them Future> getApps(List urls, {List ignoreUrls = const []}) async { List apps = [];