mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-30 21:13:28 +01:00 
			
		
		
		
	Compare commits
	
		
			17 Commits
		
	
	
		
			v0.6.3-bet
			...
			v0.6.9-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 68d903e092 | ||
|  | c47b752344 | ||
|  | 62a05996cf | ||
|  | 1cda941fbe | ||
|  | 49cb908d04 | ||
|  | 139f44d31d | ||
|  | ed955ac6a2 | ||
|  | f3ead6caf1 | ||
|  | 97ab723d04 | ||
|  | ed4a26d348 | ||
|  | bd5f21984e | ||
|  | 5037d77b14 | ||
|  | c9711c7734 | ||
|  | 76e98feeb7 | ||
|  | 03da23f77a | ||
|  | 9b99e2b302 | ||
|  | e746ca890a | 
| @@ -30,7 +30,25 @@ | ||||
|         <meta-data | ||||
|             android:name="flutterEmbedding" | ||||
|             android:value="2" /> | ||||
|         <service | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService" | ||||
|             android:permission="android.permission.BIND_JOB_SERVICE" | ||||
|             android:exported="false"/> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver" | ||||
|             android:exported="false"/> | ||||
|         <receiver | ||||
|             android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver" | ||||
|             android:enabled="false" | ||||
|             android:exported="false"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.BOOT_COMPLETED" /> | ||||
|             </intent-filter> | ||||
|         </receiver> | ||||
|     </application> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||
|     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | ||||
|     <uses-permission android:name="android.permission.WAKE_LOCK"/> | ||||
|     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> | ||||
| </manifest> | ||||
| @@ -1,112 +0,0 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class APKMirror implements AppSource { | ||||
|   @override | ||||
|   late String host = 'apkmirror.com'; | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw notValidURL(runtimeType.toString()); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl#whatsnew'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { | ||||
|     var originalUri = Uri.parse(apkUrl); | ||||
|     var res = await get(originalUri); | ||||
|     if (res.statusCode != 200) { | ||||
|       throw false; | ||||
|     } | ||||
|     var href = | ||||
|         parse(res.body).querySelector('.downloadButton')?.attributes['href']; | ||||
|     if (href == null) { | ||||
|       throw false; | ||||
|     } | ||||
|     var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: { | ||||
|       'User-Agent': | ||||
|           'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' | ||||
|     }); | ||||
|     if (res2.statusCode != 200) { | ||||
|       throw false; | ||||
|     } | ||||
|     var links = parse(res2.body) | ||||
|         .querySelectorAll('a') | ||||
|         .where((element) => element.innerHtml == 'here') | ||||
|         .map((e) => e.attributes['href']) | ||||
|         .where((element) => element != null) | ||||
|         .toList(); | ||||
|     if (links.isEmpty) { | ||||
|       throw false; | ||||
|     } | ||||
|     return '${originalUri.origin}${links[0]}'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/feed')); | ||||
|     if (res.statusCode != 200) { | ||||
|       throw couldNotFindReleases; | ||||
|     } | ||||
|     var nextUrl = parse(res.body) | ||||
|         .querySelector('item') | ||||
|         ?.querySelector('link') | ||||
|         ?.nextElementSibling | ||||
|         ?.innerHtml; | ||||
|     if (nextUrl == null) { | ||||
|       throw couldNotFindReleases; | ||||
|     } | ||||
|     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; | ||||
|     } | ||||
|     var html2 = parse(res2.body); | ||||
|     var origin = Uri.parse(standardUrl).origin; | ||||
|     List<String> apkUrls = html2 | ||||
|         .querySelectorAll('.apkm-badge') | ||||
|         .map((e) => e.innerHtml != 'APK' | ||||
|             ? '' | ||||
|             : e.previousElementSibling?.attributes['href'] ?? '') | ||||
|         .where((element) => element.isNotEmpty) | ||||
|         .map((e) => '$origin$e') | ||||
|         .toList(); | ||||
|     if (apkUrls.isEmpty) { | ||||
|       throw noAPKFound; | ||||
|     } | ||||
|     var version = html2.querySelector('span.active.accent_color')?.innerHtml; | ||||
|     if (version == null) { | ||||
|       throw couldNotFindLatestVersion; | ||||
|     } | ||||
|     return APKDetails(version, apkUrls); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); | ||||
|     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); | ||||
|     return AppNames(names[1], names[2]); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
| @@ -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<String> 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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
|   } | ||||
| @@ -72,7 +72,7 @@ class GitHub implements AppSource { | ||||
|  | ||||
|         if (regexFilter != null && | ||||
|             !RegExp(regexFilter) | ||||
|                 .hasMatch((releases[i]['tag_name'] as String).trim())) { | ||||
|                 .hasMatch((releases[i]['name'] as String).trim())) { | ||||
|           continue; | ||||
|         } | ||||
|         var apkUrls = getReleaseAPKUrls(releases[i]); | ||||
| @@ -84,14 +84,14 @@ class GitHub implements AppSource { | ||||
|         break; | ||||
|       } | ||||
|       if (targetRelease == null) { | ||||
|         throw couldNotFindReleases; | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       if ((targetRelease['apkUrls'] as List<String>).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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -28,6 +28,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     values = widget.defaultValues; | ||||
|     valid = widget.initValid; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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,97 @@ 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<String, List<String>> 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')), | ||||
|             ], | ||||
|           ); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|  | ||||
| String list2FriendlyString(List<String> list) { | ||||
|   return list.length == 2 | ||||
|       ? '${list[0]} and ${list[1]}' | ||||
|       : list | ||||
|           .asMap() | ||||
|           .entries | ||||
|           .map((e) => | ||||
|               e.value + | ||||
|               (e.key == list.length - 1 | ||||
|                   ? '' | ||||
|                   : e.key == list.length - 2 | ||||
|                       ? ', and ' | ||||
|                       : ', ')) | ||||
|           .join(''); | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'dart:io'; | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| @@ -10,52 +11,52 @@ import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:workmanager/workmanager.dart'; | ||||
| import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
|  | ||||
| const String currentVersion = '0.6.3'; | ||||
| const String currentVersion = '0.6.9'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| const String bgUpdateCheckTaskName = 'bg-update-check'; | ||||
| const int bgUpdateCheckAlarmId = 666; | ||||
|  | ||||
| bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   await AndroidAlarmManager.initialize(); | ||||
|   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||
|       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||
|       : null; | ||||
|   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<String> 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) { | ||||
|         String nextTaskName = | ||||
|             '$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; | ||||
|         Workmanager().registerOneOffTask(nextTaskName, nextTaskName, | ||||
|             constraints: Constraints(networkType: NetworkType.connected), | ||||
|             initialDelay: Duration( | ||||
|                 minutes: e is RateLimitError ? e.remainingMinutes : 15), | ||||
|             inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); | ||||
|         AndroidAlarmManager.oneShot( | ||||
|             Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15), | ||||
|             Random().nextInt(pow(2, 31) as int), | ||||
|             bgUpdateCheck, | ||||
|             params: { | ||||
|               'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||
|             }); | ||||
|       } else { | ||||
|         err = e.toString(); | ||||
|       } | ||||
|     } | ||||
|     List<App> newUpdates = appsProvider | ||||
|         .getExistingUpdates(installedOnly: true) | ||||
|         .findExistingUpdates(installedOnly: true) | ||||
|         .where((id) => !existingUpdateIds.contains(id)) | ||||
|         .map((e) => appsProvider.apps[e]!.app) | ||||
|         .toList(); | ||||
| @@ -80,24 +81,14 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
|     if (err != null) { | ||||
|       throw err; | ||||
|     } | ||||
|     return Future.value(true); | ||||
|   } catch (e) { | ||||
|     notificationsProvider | ||||
|         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||
|     return Future.error(false); | ||||
|   } finally { | ||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| void bgTaskCallback() { | ||||
|   // Background process callback | ||||
|   Workmanager().executeTask((task, inputData) async { | ||||
|     return await bgUpdateCheck(inputData?['ignoreAfter']); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||
| @@ -106,16 +97,10 @@ void main() async { | ||||
|     ); | ||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||
|   } | ||||
|   Workmanager().initialize( | ||||
|     bgTaskCallback, | ||||
|   ); | ||||
|   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()) | ||||
|     ], | ||||
| @@ -165,17 +150,14 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|       if (existingUpdateInterval != settingsProvider.updateInterval) { | ||||
|         existingUpdateInterval = settingsProvider.updateInterval; | ||||
|         if (existingUpdateInterval == 0) { | ||||
|           Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); | ||||
|           AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); | ||||
|         } else { | ||||
|           Workmanager().registerPeriodicTask( | ||||
|               bgUpdateCheckTaskName, bgUpdateCheckTaskName, | ||||
|               frequency: Duration(minutes: existingUpdateInterval), | ||||
|               initialDelay: Duration(minutes: existingUpdateInterval), | ||||
|               constraints: Constraints(networkType: NetworkType.connected), | ||||
|               existingWorkPolicy: ExistingWorkPolicy.replace, | ||||
|               backoffPolicy: BackoffPolicy.linear, | ||||
|               backoffPolicyDelay: | ||||
|                   const Duration(minutes: minUpdateIntervalMinutes)); | ||||
|           AndroidAlarmManager.periodic( | ||||
|               Duration(minutes: existingUpdateInterval), | ||||
|               bgUpdateCheckAlarmId, | ||||
|               bgUpdateCheck, | ||||
|               rescheduleOnReboot: true, | ||||
|               wakeup: true); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   | ||||
| @@ -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<List<String>> getUrls(List<String> args) async { | ||||
|     if (args.length != requiredArgs.length) { | ||||
|       throw 'Wrong number of arguments provided'; | ||||
|       throw ObtainiumError('Wrong number of arguments provided'); | ||||
|     } | ||||
|     List<String> urls = []; | ||||
|     var page = 1; | ||||
|   | ||||
| @@ -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<AddAppPage> { | ||||
|                                             : []; | ||||
|                                         validAdditionalData = source != null | ||||
|                                             ? sourceProvider | ||||
|                                                 .doesSourceHaveRequiredAdditionalData( | ||||
|                                                 .ifSourceAppsRequireAdditionalData( | ||||
|                                                     source) | ||||
|                                             : true; | ||||
|                                       } | ||||
| @@ -114,21 +115,20 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                                 .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<AddAppPage> { | ||||
|                                                         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<AddAppPage> { | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                                 children: [ | ||||
|                               // const SizedBox( | ||||
|                               //   height: 48, | ||||
|                               // ), | ||||
|                               const Text( | ||||
|                                 'Supported Sources:', | ||||
|                               ), | ||||
|   | ||||
| @@ -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<AppPage> { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     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); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
| @@ -63,6 +62,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                                     Image.memory( | ||||
|                                       app!.installedInfo!.icon!, | ||||
|                                       height: 150, | ||||
|                                       gaplessPlayback: true, | ||||
|                                     ) | ||||
|                                   ]) | ||||
|                             : Container(), | ||||
| @@ -216,9 +216,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                         Expanded( | ||||
|                             child: ElevatedButton( | ||||
|                                 onPressed: (app?.app.installedVersion == null || | ||||
|                                             appsProvider | ||||
|                                                 .checkAppObjectForUpdate( | ||||
|                                                     app!.app)) && | ||||
|                                             app?.app.installedVersion != | ||||
|                                                 app?.app.latestVersion) && | ||||
|                                         !appsProvider.areDownloadsRunning() | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
| @@ -230,11 +229,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                           } | ||||
|                                         }).catchError((e) { | ||||
|                                           ScaffoldMessenger.of(context) | ||||
|                                               .showSnackBar( | ||||
|                                             SnackBar( | ||||
|                                                 content: Text(e.toString())), | ||||
|                                           ); | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
|   | ||||
| @@ -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'; | ||||
| @@ -23,6 +24,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|   var updatesOnlyFilter = | ||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||
|   Set<String> selectedIds = {}; | ||||
|   DateTime? refreshingSince; | ||||
|  | ||||
|   clearSelected() { | ||||
|     if (selectedIds.isNotEmpty) { | ||||
| @@ -119,28 +121,46 @@ class AppsPageState extends State<AppsPage> { | ||||
|       sortedApps = sortedApps.reversed.toList(); | ||||
|     } | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = appsProvider | ||||
|         .getExistingUpdates(installedOnly: true) | ||||
|     var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = existingUpdates | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : 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)) | ||||
|         .toList(); | ||||
|  | ||||
|     if (settingsProvider.pinUpdates) { | ||||
|       var temp = []; | ||||
|       sortedApps = sortedApps.where((sa) { | ||||
|         if (existingUpdates.contains(sa.app.id)) { | ||||
|           temp.add(sa); | ||||
|           return false; | ||||
|         } | ||||
|         return true; | ||||
|       }).toList(); | ||||
|       sortedApps = [...temp, ...sortedApps]; | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
|           onRefresh: () { | ||||
|             HapticFeedback.lightImpact(); | ||||
|             setState(() { | ||||
|               refreshingSince = DateTime.now(); | ||||
|             }); | ||||
|             return appsProvider.checkUpdates().catchError((e) { | ||||
|               ScaffoldMessenger.of(context).showSnackBar( | ||||
|                 SnackBar(content: Text(e.toString())), | ||||
|               ); | ||||
|               showError(e, context); | ||||
|             }).whenComplete(() { | ||||
|               setState(() { | ||||
|                 refreshingSince = null; | ||||
|               }); | ||||
|             }); | ||||
|           }, | ||||
|           child: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -157,6 +177,17 @@ class AppsPageState extends State<AppsPage> { | ||||
|                               style: Theme.of(context).textTheme.headlineMedium, | ||||
|                               textAlign: TextAlign.center, | ||||
|                             ))), | ||||
|             if (refreshingSince != null) | ||||
|               SliverToBoxAdapter( | ||||
|                 child: LinearProgressIndicator( | ||||
|                   value: appsProvider.apps.values | ||||
|                           .where((element) => !(element.app.lastUpdateCheck | ||||
|                                   ?.isBefore(refreshingSince!) ?? | ||||
|                               true)) | ||||
|                           .length / | ||||
|                       appsProvider.apps.length, | ||||
|                 ), | ||||
|               ), | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
| @@ -168,7 +199,10 @@ class AppsPageState extends State<AppsPage> { | ||||
|                   toggleAppSelected(sortedApps[index].app.id); | ||||
|                 }, | ||||
|                 leading: sortedApps[index].installedInfo != null | ||||
|                     ? Image.memory(sortedApps[index].installedInfo!.icon!) | ||||
|                     ? Image.memory( | ||||
|                         sortedApps[index].installedInfo!.icon!, | ||||
|                         gaplessPlayback: true, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 title: Text(sortedApps[index].installedInfo?.name ?? | ||||
|                     sortedApps[index].app.name), | ||||
| @@ -327,10 +361,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                   ); | ||||
|                                 }).then((values) { | ||||
|                               if (values != null) { | ||||
|                                 bool shouldInstallUpdates = | ||||
|                                     values.isEmpty || values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = values.isEmpty || | ||||
|                                     (values.length >= 2 && values[1] == 'true'); | ||||
|                                 bool shouldInstallUpdates = values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = values[1] == 'true'; | ||||
|                                 settingsProvider | ||||
|                                     .getInstallPermission() | ||||
|                                     .then((_) { | ||||
| @@ -347,9 +379,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                       .downloadAndInstallLatestApps( | ||||
|                                           toInstall, context) | ||||
|                                       .catchError((e) { | ||||
|                                     ScaffoldMessenger.of(context).showSnackBar( | ||||
|                                       SnackBar(content: Text(e.toString())), | ||||
|                                     ); | ||||
|                                     showError(e, context); | ||||
|                                   }); | ||||
|                                 }); | ||||
|                               } | ||||
|   | ||||
| @@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> { | ||||
|           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||
|               .currentState | ||||
|               ?.clearSelected(); | ||||
|           // return !appsPageKey.currentState?.clearSelected(); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<ImportExportPage> { | ||||
|                                           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<ImportExportPage> { | ||||
|                                               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<ImportExportPage> { | ||||
|                                       }); | ||||
|                                       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<ImportExportPage> { | ||||
|                                               }); | ||||
|                                         } | ||||
|                                       }).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<ImportExportPage> { | ||||
|                           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<ImportExportPage> { | ||||
|                                                               .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<ImportExportPage> { | ||||
|                                                         importInProgress = | ||||
|                                                             false; | ||||
|                                                       }); | ||||
|                                                       ScaffoldMessenger.of( | ||||
|                                                               context) | ||||
|                                                           .showSnackBar( | ||||
|                                                         SnackBar( | ||||
|                                                             content: Text( | ||||
|                                                                 e.toString())), | ||||
|                                                       ); | ||||
|                                                       showError(e, context); | ||||
|                                                     }); | ||||
|                                                   } | ||||
|                                                 }); | ||||
|   | ||||
| @@ -21,26 +21,9 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Settings'), | ||||
|           SliverToBoxAdapter( | ||||
|               child: Padding( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: settingsProvider.prefs == null | ||||
|                       ? const SizedBox() | ||||
|                       : Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               'Appearance', | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|                                 decoration: | ||||
|                                     const InputDecoration(labelText: 'Theme'), | ||||
|  | ||||
|     var themeDropdown = DropdownButtonFormField( | ||||
|         decoration: const InputDecoration(labelText: 'Theme'), | ||||
|         value: settingsProvider.theme, | ||||
|         items: const [ | ||||
|           DropdownMenuItem( | ||||
| @@ -60,13 +43,10 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           if (value != null) { | ||||
|             settingsProvider.theme = value; | ||||
|           } | ||||
|                                 }), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|                                 decoration: | ||||
|                                     const InputDecoration(labelText: 'Colour'), | ||||
|         }); | ||||
|  | ||||
|     var colourDropdown = DropdownButtonFormField( | ||||
|         decoration: const InputDecoration(labelText: 'Colour'), | ||||
|         value: settingsProvider.colour, | ||||
|         items: const [ | ||||
|           DropdownMenuItem( | ||||
| @@ -82,28 +62,18 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           if (value != null) { | ||||
|             settingsProvider.colour = value; | ||||
|           } | ||||
|                                 }), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.start, | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 Expanded( | ||||
|                                     child: DropdownButtonFormField( | ||||
|                                         decoration: const InputDecoration( | ||||
|                                             labelText: 'App Sort By'), | ||||
|         }); | ||||
|  | ||||
|     var sortDropdown = DropdownButtonFormField( | ||||
|         decoration: const InputDecoration(labelText: 'App Sort By'), | ||||
|         value: settingsProvider.sortColumn, | ||||
|         items: const [ | ||||
|           DropdownMenuItem( | ||||
|                                             value: | ||||
|                                                 SortColumnSettings.authorName, | ||||
|             value: SortColumnSettings.authorName, | ||||
|             child: Text('Author/Name'), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
|                                             value: | ||||
|                                                 SortColumnSettings.nameAuthor, | ||||
|             value: SortColumnSettings.nameAuthor, | ||||
|             child: Text('Name/Author'), | ||||
|           ), | ||||
|           DropdownMenuItem( | ||||
| @@ -115,14 +85,10 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           if (value != null) { | ||||
|             settingsProvider.sortColumn = value; | ||||
|           } | ||||
|                                         })), | ||||
|                                 const SizedBox( | ||||
|                                   width: 16, | ||||
|                                 ), | ||||
|                                 Expanded( | ||||
|                                     child: DropdownButtonFormField( | ||||
|                                         decoration: const InputDecoration( | ||||
|                                             labelText: 'App Sort Order'), | ||||
|         }); | ||||
|  | ||||
|     var orderDropdown = DropdownButtonFormField( | ||||
|         decoration: const InputDecoration(labelText: 'App Sort Order'), | ||||
|         value: settingsProvider.sortOrder, | ||||
|         items: const [ | ||||
|           DropdownMenuItem( | ||||
| @@ -138,38 +104,11 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           if (value != null) { | ||||
|             settingsProvider.sortOrder = value; | ||||
|           } | ||||
|                                         })), | ||||
|                               ], | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 const Text('Show Source Webpage in App View'), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.showAppWebpage, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.showAppWebpage = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               'Updates', | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             DropdownButtonFormField( | ||||
|         }); | ||||
|  | ||||
|     var intervalDropdown = DropdownButtonFormField( | ||||
|         decoration: const InputDecoration( | ||||
|                                     labelText: | ||||
|                                         'Background Update Checking Interval'), | ||||
|             labelText: 'Background Update Checking Interval'), | ||||
|         value: settingsProvider.updateInterval, | ||||
|         items: updateIntervals.map((e) { | ||||
|           int displayNum = (e < 60 | ||||
| @@ -187,25 +126,104 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           String display = e == 0 | ||||
|               ? 'Never - Manual Only' | ||||
|               : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; | ||||
|                                   return DropdownMenuItem( | ||||
|                                       value: e, child: Text(display)); | ||||
|           return DropdownMenuItem(value: e, child: Text(display)); | ||||
|         }).toList(), | ||||
|         onChanged: (value) { | ||||
|           if (value != null) { | ||||
|             settingsProvider.updateInterval = value; | ||||
|           } | ||||
|                                 }), | ||||
|                             const SizedBox( | ||||
|                               height: 8, | ||||
|                             ), | ||||
|         }); | ||||
|  | ||||
|     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: <Widget>[ | ||||
|           const CustomAppBar(title: 'Settings'), | ||||
|           SliverToBoxAdapter( | ||||
|               child: Padding( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: settingsProvider.prefs == null | ||||
|                       ? const SizedBox() | ||||
|                       : Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               'Longer intervals recommended for large App collections', | ||||
|                               style: Theme.of(context) | ||||
|                                   .textTheme | ||||
|                                   .labelMedium! | ||||
|                                   .merge(const TextStyle( | ||||
|                                       fontStyle: FontStyle.italic)), | ||||
|                               'Appearance', | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             themeDropdown, | ||||
|                             height16, | ||||
|                             colourDropdown, | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.start, | ||||
|                               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                               children: [ | ||||
|                                 Expanded(child: sortDropdown), | ||||
|                                 const SizedBox( | ||||
|                                   width: 16, | ||||
|                                 ), | ||||
|                                 Expanded(child: orderDropdown), | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 const Text('Show Source Webpage in App View'), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.showAppWebpage, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.showAppWebpage = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 const Text('Pin Updates to Top of Apps View'), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.pinUpdates, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.pinUpdates = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Text( | ||||
|                               'Updates', | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             intervalDropdown, | ||||
|                             const Divider( | ||||
|                               height: 48, | ||||
|                             ), | ||||
| @@ -214,42 +232,13 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                               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<Color>( | ||||
| @@ -267,9 +256,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                     style: Theme.of(context).textTheme.bodySmall, | ||||
|                   ), | ||||
|                 ), | ||||
|                 const SizedBox( | ||||
|                   height: 16, | ||||
|                 ), | ||||
|                 height16, | ||||
|               ], | ||||
|             ), | ||||
|           ) | ||||
|   | ||||
| @@ -25,15 +25,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,54 +41,49 @@ class AppsProvider with ChangeNotifier { | ||||
|   Map<String, AppInMemory> 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<FGBGType>? foregroundStream; | ||||
|   late StreamSubscription<FGBGType>? 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, | ||||
|       {bool useExistingIfExists = true}) async { | ||||
|   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'); | ||||
|  | ||||
|     if (downloadedFile.existsSync()) { | ||||
|       downloadedFile.deleteSync(); | ||||
|     } | ||||
|     var length = response.contentLength; | ||||
|     var received = 0; | ||||
|     double? progress; | ||||
|       var sink = downloadFile.openWrite(); | ||||
|     var sink = downloadedFile.openWrite(); | ||||
|  | ||||
|     await response.stream.map((s) { | ||||
|       received += s.length; | ||||
| @@ -106,58 +101,52 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|  | ||||
|     if (response.statusCode != 200) { | ||||
|         downloadFile.deleteSync(); | ||||
|       downloadedFile.deleteSync(); | ||||
|       throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|     } | ||||
|     } | ||||
|     return downloadFile; | ||||
|     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<DownloadedApp> downloadApp(App app, | ||||
|       {bool showOccasionalProgressToast = false}) async { | ||||
|   Future<DownloadedApk> downloadApp(App app) async { | ||||
|     var fileName = | ||||
|         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; | ||||
|     String downloadUrl = await SourceProvider() | ||||
|         .getSource(app.url) | ||||
|         .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]); | ||||
|     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; | ||||
|       if (apps[app.id] != null) { | ||||
|         throw IDChangedError(); | ||||
|       } | ||||
|       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]); | ||||
|       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 | ||||
| @@ -165,32 +154,34 @@ class AppsProvider with ChangeNotifier { | ||||
|       .isNotEmpty; | ||||
|  | ||||
|   Future<bool> 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<void> askUserToReturnToForeground(BuildContext context, | ||||
|       {bool waitForFG = false}) async { | ||||
|   Future<void> waitForUserToReturnToForeground(BuildContext context) async { | ||||
|     NotificationsProvider notificationsProvider = | ||||
|         context.read<NotificationsProvider>(); | ||||
|     if (!isForeground) { | ||||
|       await notificationsProvider.notify(completeInstallationNotification, | ||||
|           cancelExisting: true); | ||||
|       if (waitForFG) { | ||||
|         await FGBGEvents.stream.first == FGBGType.foreground; | ||||
|       while (await FGBGEvents.stream.first != FGBGType.foreground) {} | ||||
|       await notificationsProvider.cancel(completeInstallationNotification.id); | ||||
|     } | ||||
|   } | ||||
|   } | ||||
|  | ||||
|   // Unfortunately this 'await' does not actually wait for the APK to finish installing | ||||
|   // 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<void> installApk(DownloadedApp file) async { | ||||
|   Future<void> installApk(DownloadedApk file) async { | ||||
|     var newInfo = await PackageArchiveInfo.fromPath(file.file.path); | ||||
|     AppInfo? appInfo; | ||||
|     try { | ||||
| @@ -200,7 +191,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!) { | ||||
| @@ -209,17 +200,25 @@ 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<String?> selectApkUrl(App app, BuildContext? context) async { | ||||
|   Future<String?> 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]; | ||||
|     // get device supported architecture | ||||
|     List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis; | ||||
|  | ||||
|     if (app.apkUrls.length > 1 && context != null) { | ||||
|       apkUrl = await showDialog( | ||||
|           context: context, | ||||
|           builder: (BuildContext ctx) { | ||||
|             return APKPicker(app: app, initVal: apkUrl); | ||||
|             return APKPicker( | ||||
|               app: app, | ||||
|               initVal: apkUrl, | ||||
|               archs: archs, | ||||
|             ); | ||||
|           }); | ||||
|     } | ||||
|     // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) | ||||
| @@ -239,15 +238,6 @@ class AppsProvider with ChangeNotifier { | ||||
|     return apkUrl; | ||||
|   } | ||||
|  | ||||
|   Map<String, List<String>> addToErrorMap( | ||||
|       Map<String, List<String>> 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 | ||||
| @@ -256,42 +246,41 @@ class AppsProvider with ChangeNotifier { | ||||
|   Future<List<String>> downloadAndInstallLatestApps( | ||||
|       List<String> appIds, BuildContext? context) async { | ||||
|     List<String> 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<String, List<String>> errors = {}; | ||||
|  | ||||
|     List<DownloadedApp?> downloadedFiles = | ||||
|     // Download APKs for all Apps to be installed | ||||
|     MultiAppMultiError errors = MultiAppMultiError(); | ||||
|     List<DownloadedApk?> 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<DownloadedApp> silentUpdates = []; | ||||
|     List<DownloadedApp> regularInstalls = []; | ||||
|     // Separate the Apps to install into silent and regular lists | ||||
|     List<DownloadedApk> silentUpdates = []; | ||||
|     List<DownloadedApk> regularInstalls = []; | ||||
|     for (var f in downloadedFiles) { | ||||
|       bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); | ||||
|       if (willBeSilent) { | ||||
| @@ -301,10 +290,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<DownloadedApp> moveObtainiumToEnd(List<DownloadedApp> items) { | ||||
|     List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { | ||||
|       String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; | ||||
|       DownloadedApp? temp; | ||||
|       DownloadedApk? temp; | ||||
|       items.removeWhere((element) { | ||||
|         bool res = element.appId == obtainiumId; | ||||
|         if (res) { | ||||
| @@ -313,44 +305,38 @@ class AppsProvider with ChangeNotifier { | ||||
|         return res; | ||||
|       }); | ||||
|       if (temp != null) { | ||||
|         items.add(temp!); | ||||
|         items = [temp!, ...items]; | ||||
|       } | ||||
|       return items; | ||||
|     } | ||||
|  | ||||
|     // TODO: Remove below line if silentupdates are ever figured out | ||||
|     regularInstalls.addAll(silentUpdates); | ||||
|     silentUpdates = moveObtainiumToStart(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToStart(regularInstalls); | ||||
|  | ||||
|     silentUpdates = moveObtainiumToEnd(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToEnd(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) { | ||||
|     // Do regular installs | ||||
|     if (regularInstalls.isNotEmpty && context != null) { | ||||
|       // ignore: use_build_context_synchronously | ||||
|         await askUserToReturnToForeground(context, waitForFG: true); | ||||
|       } | ||||
|       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; | ||||
|     } | ||||
|  | ||||
|     NotificationsProvider().cancel(UpdateNotification([]).id); | ||||
|  | ||||
|     return downloadedFiles.map((e) => e!.appId).toList(); | ||||
|   } | ||||
|  | ||||
| @@ -363,40 +349,6 @@ class AppsProvider with ChangeNotifier { | ||||
|     return appsDir; | ||||
|   } | ||||
|  | ||||
|   // Delete all stored APKs except those likely to still be needed | ||||
|   Future<void> deleteSavedAPKs() async { | ||||
|     List<FileSystemEntity>? 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<AppInfo?> getInstalledInfo(String? packageName) async { | ||||
|     if (packageName != null) { | ||||
|       try { | ||||
| @@ -408,24 +360,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 { | ||||
| @@ -436,7 +401,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     return modded ? app : null; | ||||
|   } | ||||
|  | ||||
|   Future<void> loadApps({shouldCorrectInstallStatus = true}) async { | ||||
|   Future<void> loadApps() async { | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
|     } | ||||
| @@ -467,28 +432,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<App> modifiedApps = []; | ||||
|     for (var app in apps.values) { | ||||
|         var moddedApp = correctInstallStatus(app.app, app.installedInfo); | ||||
|       var moddedApp = | ||||
|           getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); | ||||
|       if (moddedApp != null) { | ||||
|         modifiedApps.add(moddedApp); | ||||
|       } | ||||
|     } | ||||
|     if (modifiedApps.isNotEmpty) { | ||||
|         await saveApps(modifiedApps, shouldCorrectInstallStatus: false); | ||||
|       } | ||||
|       await saveApps(modifiedApps); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> saveApps(List<App> 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())); | ||||
| @@ -514,15 +477,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<App?> getUpdate(String appId, | ||||
|       {bool shouldCorrectInstallStatus = true}) async { | ||||
|   Future<App?> checkUpdate(String appId) async { | ||||
|     App? currentApp = apps[appId]!.app; | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     App newApp = await sourceProvider.getApp( | ||||
| @@ -535,51 +490,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<List<App>> checkUpdates( | ||||
|       {DateTime? ignoreAfter, | ||||
|       bool immediatelyThrowRateLimitError = false, | ||||
|       bool shouldCorrectInstallStatus = true, | ||||
|       bool immediatelyThrowSocketError = false}) async { | ||||
|       {DateTime? ignoreAppsCheckedAfter, | ||||
|       bool throwErrorsForRetry = false}) async { | ||||
|     List<App> updates = []; | ||||
|     Map<String, List<String>> errors = {}; | ||||
|     MultiAppMultiError errors = MultiAppMultiError(); | ||||
|     if (!gettingUpdates) { | ||||
|       gettingUpdates = true; | ||||
|  | ||||
|       try { | ||||
|         List<String> appIds = apps.keys.toList(); | ||||
|         if (ignoreAfter != null) { | ||||
|           appIds = appIds | ||||
|               .where((id) => | ||||
|                   apps[id]!.app.lastUpdateCheck == null || | ||||
|                   apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) | ||||
|         List<String> 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); | ||||
| @@ -589,18 +532,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<String> getExistingUpdates( | ||||
|   List<String> findExistingUpdates( | ||||
|       {bool installedOnly = false, bool nonInstalledOnly = false}) { | ||||
|     List<String> updateAppIds = []; | ||||
|     List<String> appIds = apps.keys.toList(); | ||||
| @@ -634,7 +572,6 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<int> 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<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) | ||||
|         .map((e) => App.fromJson(e)) | ||||
|         .toList(); | ||||
| @@ -659,10 +596,11 @@ class AppsProvider with ChangeNotifier { | ||||
| } | ||||
|  | ||||
| class APKPicker extends StatefulWidget { | ||||
|   const APKPicker({super.key, required this.app, this.initVal}); | ||||
|   const APKPicker({super.key, required this.app, this.initVal, this.archs}); | ||||
|  | ||||
|   final App app; | ||||
|   final String? initVal; | ||||
|   final List<String>? archs; | ||||
|  | ||||
|   @override | ||||
|   State<APKPicker> createState() => _APKPickerState(); | ||||
| @@ -680,7 +618,8 @@ class _APKPickerState extends State<APKPicker> { | ||||
|       content: Column(children: [ | ||||
|         Text('${widget.app.name} has more than one package:'), | ||||
|         const SizedBox(height: 16), | ||||
|         ...widget.app.apkUrls.map((u) => RadioListTile<String>( | ||||
|         ...widget.app.apkUrls.map( | ||||
|           (u) => RadioListTile<String>( | ||||
|               title: Text(Uri.parse(u) | ||||
|                   .pathSegments | ||||
|                   .where((element) => element.isNotEmpty) | ||||
| @@ -691,7 +630,17 @@ class _APKPickerState extends State<APKPicker> { | ||||
|                 setState(() { | ||||
|                   apkUrl = val; | ||||
|                 }); | ||||
|             })) | ||||
|               }), | ||||
|         ), | ||||
|         if (widget.archs != null) | ||||
|           const SizedBox( | ||||
|             height: 16, | ||||
|           ), | ||||
|         if (widget.archs != null) | ||||
|           Text( | ||||
|             'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}', | ||||
|             style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), | ||||
|           ), | ||||
|       ]), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|   | ||||
| @@ -55,7 +55,7 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   int get updateInterval { | ||||
|     var min = prefs?.getInt('updateInterval') ?? 180; | ||||
|     var min = prefs?.getInt('updateInterval') ?? 360; | ||||
|     if (!updateIntervals.contains(min)) { | ||||
|       var temp = updateIntervals[0]; | ||||
|       for (var i in updateIntervals) { | ||||
| @@ -123,6 +123,15 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get pinUpdates { | ||||
|     return prefs?.getBool('pinUpdates') ?? true; | ||||
|   } | ||||
|  | ||||
|   set pinUpdates(bool show) { | ||||
|     prefs?.setBool('pinUpdates', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|   } | ||||
|   | ||||
| @@ -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<String> apkUrlPrefetchModifier(String apkUrl); | ||||
| } | ||||
|  | ||||
| abstract class MassAppSource { | ||||
| abstract class MassAppUrlSource { | ||||
|   late String name; | ||||
|   late List<String> requiredArgs; | ||||
|   Future<List<String>> getUrls(List<String> args); | ||||
| @@ -160,12 +156,11 @@ class SourceProvider { | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     SourceForge(), | ||||
|     // APKMirror() | ||||
|     SourceForge() | ||||
|   ]; | ||||
|  | ||||
|   // Add more mass source classes here so they are available via the service | ||||
|   List<MassAppSource> massSources = [GitHubStars()]; | ||||
|   // Add more mass url source classes here so they are available via the service | ||||
|   List<MassAppUrlSource> massUrlSources = [GitHubStars()]; | ||||
|  | ||||
|   AppSource getSource(String url) { | ||||
|     url = preStandardizeUrl(url); | ||||
| @@ -177,12 +172,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 +212,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<String, dynamic> of URLs and errors | ||||
|   // Returns errors in [results, errors] instead of throwing them | ||||
|   Future<List<dynamic>> getApps(List<String> urls, | ||||
|       {List<String> ignoreUrls = const []}) async { | ||||
|     List<App> apps = []; | ||||
|   | ||||
							
								
								
									
										18
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -1,6 +1,13 @@ | ||||
| # Generated by pub | ||||
| # See https://dart.dev/tools/pub/glossary#lockfile | ||||
| packages: | ||||
|   android_alarm_manager_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: android_alarm_manager_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   animations: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -358,7 +365,7 @@ packages: | ||||
|       name: path_provider_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.20" | ||||
|     version: "2.0.21" | ||||
|   path_provider_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -470,7 +477,7 @@ packages: | ||||
|       name: share_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.1.0" | ||||
|     version: "6.2.0" | ||||
|   share_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -700,13 +707,6 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   workmanager: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: workmanager | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.5.1" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.6.3+47 # When changing this, update the tag in main() accordingly | ||||
| version: 0.6.9+53 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
| @@ -42,7 +42,6 @@ dependencies: | ||||
|   provider: ^6.0.3 | ||||
|   http: ^0.13.5 | ||||
|   webview_flutter: ^3.0.4 | ||||
|   workmanager: ^0.5.0 | ||||
|   dynamic_color: ^1.5.4 | ||||
|   html: ^0.15.0 | ||||
|   shared_preferences: ^2.0.15 | ||||
| @@ -56,6 +55,7 @@ dependencies: | ||||
|   share_plus: ^6.0.1 | ||||
|   installed_apps: ^1.3.1 | ||||
|   package_archive_info: ^0.1.0 | ||||
|   android_alarm_manager_plus: ^2.1.0 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user