mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-31 05:23:28 +01:00 
			
		
		
		
	Compare commits
	
		
			31 Commits
		
	
	
		
			v0.4.0-bet
			...
			v0.5.9-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 52ce5b19c4 | ||
|  | 03f0b6cf05 | ||
|  | 5d8d0de8de | ||
|  | 07f6d4ad2c | ||
|  | dfbb4e19a5 | ||
|  | f5fda2ca90 | ||
|  | 661dc1626c | ||
|  | dde3fc20fb | ||
|  | 017b867d8d | ||
|  | 1cb1c124eb | ||
|  | fdeb852c7b | ||
|  | 67f50ba776 | ||
|  | a0968caa5c | ||
|  | e3e945d13b | ||
|  | 61f7f171b1 | ||
|  | de07583161 | ||
|  | 49b9a65053 | ||
|  | aebc8aed76 | ||
|  | 3958425c22 | ||
|  | 0a560871cb | ||
|  | fbe4f0b49e | ||
|  | e2440a38c4 | ||
|  | 496a10a444 | ||
|  | b8bb8d1f4b | ||
|  | af033f42cb | ||
|  | e706661062 | ||
|  | 1a68b8abe6 | ||
|  | 15c0ed04d1 | ||
|  | dd193d62f2 | ||
|  | 77e1768f3b | ||
|  | da9e5aed5e | 
| @@ -13,6 +13,7 @@ Currently supported App sources: | ||||
| - [IzzyOnDroid](https://android.izzysoft.de/) | ||||
| - [Mullvad](https://mullvad.net/en/) | ||||
| - [Signal](https://signal.org/) | ||||
| - [APKMirror](https://apkmirror.com/) | ||||
|  | ||||
| ## Limitations | ||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||
|   | ||||
| @@ -30,16 +30,6 @@ | ||||
|         <meta-data | ||||
|             android:name="flutterEmbedding" | ||||
|             android:value="2" /> | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="${applicationId}.fileProvider" | ||||
|             android:exported="false" | ||||
|             android:grantUriPermissions="true"> | ||||
|             <meta-data | ||||
|                 android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|                 android:resource="@xml/file_paths" /> | ||||
|         </provider> | ||||
|  | ||||
|     </application> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||
|   | ||||
							
								
								
									
										112
									
								
								lib/app_sources/apkmirror.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								lib/app_sources/apkmirror.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| 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 = []; | ||||
| } | ||||
| @@ -9,14 +9,26 @@ class FDroid implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     RegExp standardUrlRegExB = | ||||
|         RegExp('^https?://$host/+[^/]+/+packages/+[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); | ||||
|     if (match != null) { | ||||
|       url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}'; | ||||
|     } | ||||
|     RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); | ||||
|     match = standardUrlRegExA.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw notValidURL(runtimeType.toString()); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -54,4 +66,7 @@ class FDroid implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,11 @@ | ||||
| import 'dart:convert'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class GitHub implements AppSource { | ||||
|   @override | ||||
| @@ -17,6 +21,21 @@ class GitHub implements AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   Future<String> getCredentialPrefixIfAny() async { | ||||
|     SettingsProvider settingsProvider = SettingsProvider(); | ||||
|     await settingsProvider.initializeSettings(); | ||||
|     String? creds = | ||||
|         settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); | ||||
|     return creds != null && creds.isNotEmpty ? '$creds@' : ''; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/releases'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -28,7 +47,7 @@ class GitHub implements AppSource { | ||||
|         ? additionalData[2] | ||||
|         : null; | ||||
|     Response res = await get(Uri.parse( | ||||
|         'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||
|         'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var releases = jsonDecode(res.body) as List<dynamic>; | ||||
|  | ||||
| @@ -76,7 +95,10 @@ class GitHub implements AppSource { | ||||
|       return APKDetails(version, targetRelease['apkUrls']); | ||||
|     } else { | ||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|         throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; | ||||
|         throw RateLimitError( | ||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                     60000000) | ||||
|                 .round()); | ||||
|       } | ||||
|  | ||||
|       throw couldNotFindReleases; | ||||
| @@ -120,4 +142,43 @@ class GitHub implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = ['true', 'true', '']; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = [ | ||||
|     GeneratedFormItem( | ||||
|         label: 'GitHub Personal Access Token (Increases Rate Limit)', | ||||
|         id: 'github-creds', | ||||
|         required: false, | ||||
|         additionalValidators: [ | ||||
|           (value) { | ||||
|             if (value != null && value.trim().isNotEmpty) { | ||||
|               if (value | ||||
|                       .split(':') | ||||
|                       .where((element) => element.trim().isNotEmpty) | ||||
|                       .length != | ||||
|                   2) { | ||||
|                 return 'PAT must be in this format: username:token'; | ||||
|               } | ||||
|             } | ||||
|             return null; | ||||
|           } | ||||
|         ], | ||||
|         hint: 'username:token', | ||||
|         belowWidgets: [ | ||||
|           const SizedBox( | ||||
|             height: 8, | ||||
|           ), | ||||
|           GestureDetector( | ||||
|               onTap: () { | ||||
|                 launchUrlString( | ||||
|                     'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', | ||||
|                     mode: LaunchMode.externalApplication); | ||||
|               }, | ||||
|               child: const Text( | ||||
|                 'About GitHub PATs', | ||||
|                 style: TextStyle( | ||||
|                     decoration: TextDecoration.underline, fontSize: 12), | ||||
|               )) | ||||
|         ]) | ||||
|   ]; | ||||
| } | ||||
|   | ||||
| @@ -18,6 +18,13 @@ class GitLab implements AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/-/releases'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -68,4 +75,7 @@ class GitLab implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,12 @@ class IzzyOnDroid implements AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -62,4 +68,7 @@ class IzzyOnDroid implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,13 @@ class Mullvad implements AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -48,4 +55,7 @@ class Mullvad implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,12 @@ class Signal implements AppSource { | ||||
|     return 'https://$host'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -41,4 +47,7 @@ class Signal implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,12 @@ class SourceForge implements AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
| @@ -65,4 +71,7 @@ class SourceForge implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -10,13 +10,19 @@ class GeneratedFormItem { | ||||
|   late bool required; | ||||
|   late int max; | ||||
|   late List<String? Function(String? value)> additionalValidators; | ||||
|   late String id; | ||||
|   late List<Widget> belowWidgets; | ||||
|   late String? hint; | ||||
|  | ||||
|   GeneratedFormItem( | ||||
|       {this.label = 'Input', | ||||
|       this.type = FormItemType.string, | ||||
|       this.required = true, | ||||
|       this.max = 1, | ||||
|       this.additionalValidators = const []}); | ||||
|       this.additionalValidators = const [], | ||||
|       this.id = 'input', | ||||
|       this.belowWidgets = const [], | ||||
|       this.hint}); | ||||
| } | ||||
|  | ||||
| class GeneratedForm extends StatefulWidget { | ||||
| @@ -89,7 +95,8 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               }); | ||||
|             }, | ||||
|             decoration: InputDecoration( | ||||
|                 helperText: e.value.label + (e.value.required ? ' *' : '')), | ||||
|                 helperText: e.value.label + (e.value.required ? ' *' : ''), | ||||
|                 hintText: e.value.hint), | ||||
|             minLines: e.value.max <= 1 ? null : e.value.max, | ||||
|             maxLines: e.value.max <= 1 ? 1 : e.value.max, | ||||
|             validator: (value) { | ||||
| @@ -155,7 +162,13 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|             width: 20, | ||||
|           )); | ||||
|         } | ||||
|         rowItems.add(Expanded(child: rowInput.value)); | ||||
|         rowItems.add(Expanded( | ||||
|             child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|               rowInput.value, | ||||
|               ...widget.items[rowInputs.key][rowInput.key].belowWidgets | ||||
|             ]))); | ||||
|       }); | ||||
|       rows.add(rowItems); | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										8
									
								
								lib/custom_errors.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								lib/custom_errors.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| class RateLimitError { | ||||
|   late int remainingMinutes; | ||||
|   RateLimitError(this.remainingMinutes); | ||||
|  | ||||
|   @override | ||||
|   String toString() => | ||||
|       'Too many requests (rate limited) - try again in $remainingMinutes minutes'; | ||||
| } | ||||
							
								
								
									
										149
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										149
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/home.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| @@ -13,47 +14,76 @@ import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
|  | ||||
| const String currentReleaseTag = | ||||
|     'v0.4.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|     'v0.5.9-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| const String bgUpdateCheckTaskName = 'bg-update-check'; | ||||
|  | ||||
| bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
|   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||
|       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||
|       : null; | ||||
|   var notificationsProvider = NotificationsProvider(); | ||||
|   await notificationsProvider.notify(checkingUpdatesNotification); | ||||
|   try { | ||||
|     var appsProvider = AppsProvider(); | ||||
|     await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); | ||||
|     await appsProvider.loadApps(); | ||||
|     List<String> existingUpdateIds = | ||||
|         appsProvider.getExistingUpdates(installedOnly: true); | ||||
|     DateTime nextIgnoreAfter = DateTime.now(); | ||||
|     try { | ||||
|       await appsProvider.checkUpdates(ignoreAfter: ignoreAfter); | ||||
|     } catch (e) { | ||||
|       if (e is RateLimitError) { | ||||
|         String nextTaskName = | ||||
|             '$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; | ||||
|         Workmanager().registerOneOffTask(nextTaskName, nextTaskName, | ||||
|             constraints: Constraints(networkType: NetworkType.connected), | ||||
|             initialDelay: Duration(minutes: e.remainingMinutes), | ||||
|             inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); | ||||
|       } else { | ||||
|         rethrow; | ||||
|       } | ||||
|     } | ||||
|     List<App> newUpdates = appsProvider | ||||
|         .getExistingUpdates(installedOnly: true) | ||||
|         .where((id) => !existingUpdateIds.contains(id)) | ||||
|         .map((e) => appsProvider.apps[e]!.app) | ||||
|         .toList(); | ||||
|  | ||||
|     // TODO: This silent update code doesn't work yet | ||||
|     // List<String> silentlyUpdated = await appsProvider | ||||
|     //     .downloadAndInstallLatestApp( | ||||
|     //         [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); | ||||
|     // if (silentlyUpdated.isNotEmpty) { | ||||
|     //   newUpdates = newUpdates | ||||
|     //       .where((element) => !silentlyUpdated.contains(element.id)) | ||||
|     //       .toList(); | ||||
|     //   notificationsProvider.notify( | ||||
|     //       SilentUpdateNotification( | ||||
|     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||
|     //       cancelExisting: true); | ||||
|     // } | ||||
|  | ||||
|     if (newUpdates.isNotEmpty) { | ||||
|       notificationsProvider.notify(UpdateNotification(newUpdates), | ||||
|           cancelExisting: true); | ||||
|     } | ||||
|     return Future.value(true); | ||||
|   } catch (e) { | ||||
|     notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()), | ||||
|         cancelExisting: true); | ||||
|     return Future.error(false); | ||||
|   } finally { | ||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| void bgTaskCallback() { | ||||
|   // Background update checking process | ||||
|   Workmanager().executeTask((task, taskName) async { | ||||
|     var notificationsProvider = NotificationsProvider(); | ||||
|     await notificationsProvider.notify(checkingUpdatesNotification); | ||||
|     try { | ||||
|       var appsProvider = AppsProvider(); | ||||
|       await notificationsProvider | ||||
|           .cancel(ErrorCheckingUpdatesNotification('').id); | ||||
|       await appsProvider.loadApps(); | ||||
|       // List<String> existingUpdateIds = // TODO: Uncomment this and below when it works | ||||
|       //     appsProvider.getExistingUpdates(installedOnly: true); | ||||
|       List<App> newUpdates = await appsProvider.checkUpdates(); | ||||
|       // List<String> silentlyUpdated = await appsProvider | ||||
|       //     .downloadAndInstallLatestApp( | ||||
|       //         [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); | ||||
|       // if (silentlyUpdated.isNotEmpty) { | ||||
|       //   newUpdates | ||||
|       //       .where((element) => !silentlyUpdated.contains(element.id)) | ||||
|       //       .toList(); | ||||
|       //   notificationsProvider.notify( | ||||
|       //       SilentUpdateNotification( | ||||
|       //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||
|       //       cancelExisting: true); | ||||
|       // } | ||||
|       if (newUpdates.isNotEmpty) { | ||||
|         notificationsProvider.notify(UpdateNotification(newUpdates), | ||||
|             cancelExisting: true); | ||||
|       } | ||||
|       return Future.value(true); | ||||
|     } catch (e) { | ||||
|       notificationsProvider.notify( | ||||
|           ErrorCheckingUpdatesNotification(e.toString()), | ||||
|           cancelExisting: true); | ||||
|       return Future.value(false); | ||||
|     } finally { | ||||
|       await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|     } | ||||
|   // Background process callback | ||||
|   Workmanager().executeTask((task, inputData) async { | ||||
|     return await bgUpdateCheck(inputData?['ignoreAfter']); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| @@ -78,14 +108,21 @@ void main() async { | ||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||
|       Provider(create: (context) => NotificationsProvider()) | ||||
|     ], | ||||
|     child: const MyApp(), | ||||
|     child: const Obtainium(), | ||||
|   )); | ||||
| } | ||||
|  | ||||
| var defaultThemeColour = Colors.deepPurple; | ||||
|  | ||||
| class MyApp extends StatelessWidget { | ||||
|   const MyApp({super.key}); | ||||
| class Obtainium extends StatefulWidget { | ||||
|   const Obtainium({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<Obtainium> createState() => _ObtainiumState(); | ||||
| } | ||||
|  | ||||
| class _ObtainiumState extends State<Obtainium> { | ||||
|   var existingUpdateInterval = -1; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -95,16 +132,6 @@ class MyApp extends StatelessWidget { | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } else { | ||||
|       // Register the background update task according to the user's setting | ||||
|       if (settingsProvider.updateInterval > 0) { | ||||
|         Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', | ||||
|             frequency: Duration(minutes: settingsProvider.updateInterval), | ||||
|             initialDelay: Duration(minutes: settingsProvider.updateInterval), | ||||
|             constraints: Constraints(networkType: NetworkType.connected), | ||||
|             existingWorkPolicy: ExistingWorkPolicy.replace); | ||||
|       } else { | ||||
|         Workmanager().cancelByUniqueName('bg-update-check'); | ||||
|       } | ||||
|       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||
|       if (isFirstRun) { | ||||
|         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||
| @@ -119,9 +146,27 @@ class MyApp extends StatelessWidget { | ||||
|               currentReleaseTag, | ||||
|               [], | ||||
|               0, | ||||
|               ['true']) | ||||
|               ['true'], | ||||
|               null) | ||||
|         ]); | ||||
|       } | ||||
|       // Register the background update task according to the user's setting | ||||
|       if (existingUpdateInterval != settingsProvider.updateInterval) { | ||||
|         existingUpdateInterval = settingsProvider.updateInterval; | ||||
|         if (existingUpdateInterval == 0) { | ||||
|           Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); | ||||
|         } 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)); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return DynamicColorBuilder( | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class GitHubStars implements MassAppSource { | ||||
| @@ -10,23 +12,40 @@ class GitHubStars implements MassAppSource { | ||||
|   @override | ||||
|   late List<String> requiredArgs = ['Username']; | ||||
|  | ||||
|   @override | ||||
|   Future<List<String>> getUrls(List<String> args) async { | ||||
|     if (args.length != requiredArgs.length) { | ||||
|       throw 'Wrong number of arguments provided'; | ||||
|     } | ||||
|     Response res = | ||||
|         await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); | ||||
|   Future<List<String>> getOnePageOfUserStarredUrls( | ||||
|       String username, int page) async { | ||||
|     Response res = await get(Uri.parse( | ||||
|         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); | ||||
|     if (res.statusCode == 200) { | ||||
|       return (jsonDecode(res.body) as List<dynamic>) | ||||
|           .map((e) => e['html_url'] as String) | ||||
|           .toList(); | ||||
|     } else { | ||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|         throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; | ||||
|         throw RateLimitError( | ||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                     60000000) | ||||
|                 .round()); | ||||
|       } | ||||
|  | ||||
|       throw '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'; | ||||
|     } | ||||
|     List<String> urls = []; | ||||
|     var page = 1; | ||||
|     while (true) { | ||||
|       var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); | ||||
|       urls.addAll(pageUrls); | ||||
|       if (pageUrls.length < 100) { | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|     return urls; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -52,7 +52,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                                 sourceProvider | ||||
|                                                     .getSource(value ?? '') | ||||
|                                                     .standardizeURL( | ||||
|                                                         makeUrlHttps( | ||||
|                                                         preStandardizeUrl( | ||||
|                                                             value ?? '')); | ||||
|                                               } catch (e) { | ||||
|                                                 return e is String | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| @@ -18,75 +19,102 @@ class AppPage extends StatefulWidget { | ||||
| } | ||||
|  | ||||
| class _AppPageState extends State<AppPage> { | ||||
|   AppInMemory? prevApp; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     var sourceProvider = SourceProvider(); | ||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||
|     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||
|     if (!appsProvider.areDownloadsRunning() && app != null) { | ||||
|       appsProvider.getUpdate(app.app.id).catchError((e) { | ||||
|     getUpdate(String id) { | ||||
|       appsProvider.getUpdate(id).catchError((e) { | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           SnackBar(content: Text(e.toString())), | ||||
|         ); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     var sourceProvider = SourceProvider(); | ||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||
|     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||
|     if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { | ||||
|       prevApp = app; | ||||
|       getUpdate(app.app.id); | ||||
|     } | ||||
|     return Scaffold( | ||||
|       appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: settingsProvider.showAppWebpage | ||||
|           ? WebView( | ||||
|               initialUrl: app?.app.url, | ||||
|               javascriptMode: JavascriptMode.unrestricted, | ||||
|             ) | ||||
|           : Column( | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|               children: [ | ||||
|                 Text( | ||||
|                   app?.app.name ?? 'App', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.displayLarge, | ||||
|                 ), | ||||
|                 Text( | ||||
|                   'By ${app?.app.author ?? 'Unknown'}', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.headlineMedium, | ||||
|                 ), | ||||
|                 const SizedBox( | ||||
|                   height: 32, | ||||
|                 ), | ||||
|                 GestureDetector( | ||||
|                     onTap: () { | ||||
|                       if (app?.app.url != null) { | ||||
|                         launchUrlString(app?.app.url ?? '', | ||||
|                             mode: LaunchMode.externalApplication); | ||||
|                       } | ||||
|                     }, | ||||
|                     child: Text( | ||||
|                       app?.app.url ?? '', | ||||
|                       textAlign: TextAlign.center, | ||||
|                       style: const TextStyle( | ||||
|                           decoration: TextDecoration.underline, | ||||
|                           fontStyle: FontStyle.italic, | ||||
|                           fontSize: 12), | ||||
|       body: RefreshIndicator( | ||||
|           child: settingsProvider.showAppWebpage | ||||
|               ? WebView( | ||||
|                   initialUrl: app?.app.url, | ||||
|                   javascriptMode: JavascriptMode.unrestricted, | ||||
|                 ) | ||||
|               : CustomScrollView( | ||||
|                   slivers: [ | ||||
|                     SliverFillRemaining( | ||||
|                         child: Column( | ||||
|                       mainAxisAlignment: MainAxisAlignment.center, | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           app?.app.name ?? 'App', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.displayLarge, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'By ${app?.app.author ?? 'Unknown'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.headlineMedium, | ||||
|                         ), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         GestureDetector( | ||||
|                             onTap: () { | ||||
|                               if (app?.app.url != null) { | ||||
|                                 launchUrlString(app?.app.url ?? '', | ||||
|                                     mode: LaunchMode.externalApplication); | ||||
|                               } | ||||
|                             }, | ||||
|                             child: Text( | ||||
|                               app?.app.url ?? '', | ||||
|                               textAlign: TextAlign.center, | ||||
|                               style: const TextStyle( | ||||
|                                   decoration: TextDecoration.underline, | ||||
|                                   fontStyle: FontStyle.italic, | ||||
|                                   fontSize: 12), | ||||
|                             )), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Installed Version: ${app?.app.installedVersion ?? 'None'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
|                         const SizedBox( | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: const TextStyle( | ||||
|                               fontStyle: FontStyle.italic, fontSize: 12), | ||||
|                         ) | ||||
|                       ], | ||||
|                     )), | ||||
|                 const SizedBox( | ||||
|                   height: 32, | ||||
|                   ], | ||||
|                 ), | ||||
|                 Text( | ||||
|                   'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.bodyLarge, | ||||
|                 ), | ||||
|                 Text( | ||||
|                   'Installed Version: ${app?.app.installedVersion ?? 'None'}', | ||||
|                   textAlign: TextAlign.center, | ||||
|                   style: Theme.of(context).textTheme.bodyLarge, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           onRefresh: () async { | ||||
|             if (app != null) { | ||||
|               getUpdate(app.app.id); | ||||
|             } | ||||
|           }), | ||||
|       bottomSheet: Padding( | ||||
|           padding: EdgeInsets.fromLTRB( | ||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||
| @@ -187,22 +215,40 @@ class _AppPageState extends State<AppPage> { | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       showDialog( | ||||
|                                       showDialog<List<String>>( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return GeneratedFormModal( | ||||
|                                                 title: 'Additional Options', | ||||
|                                                 items: source | ||||
|                                                     .additionalDataFormItems, | ||||
|                                                 items: [ | ||||
|                                                   ...source | ||||
|                                                       .additionalDataFormItems, | ||||
|                                                   [ | ||||
|                                                     GeneratedFormItem( | ||||
|                                                         label: 'App Name', | ||||
|                                                         required: true) | ||||
|                                                   ] | ||||
|                                                 ], | ||||
|                                                 defaultValues: app != null | ||||
|                                                     ? app.app.additionalData | ||||
|                                                     : source | ||||
|                                                         .additionalDataDefaults); | ||||
|                                                     ? [ | ||||
|                                                         ...app | ||||
|                                                             .app.additionalData, | ||||
|                                                         app.app.name | ||||
|                                                       ] | ||||
|                                                     : [ | ||||
|                                                         ...source | ||||
|                                                             .additionalDataDefaults | ||||
|                                                       ]); | ||||
|                                           }).then((values) { | ||||
|                                         if (app != null && values != null) { | ||||
|                                           var changedApp = app.app; | ||||
|                                           var name = values.removeLast(); | ||||
|                                           changedApp.name = name; | ||||
|                                           changedApp.additionalData = values; | ||||
|                                           appsProvider.saveApps([changedApp]); | ||||
|                                           appsProvider.saveApps( | ||||
|                                               [changedApp]).then((value) { | ||||
|                                             getUpdate(changedApp.id); | ||||
|                                           }); | ||||
|                                         } | ||||
|                                       }); | ||||
|                                     }, | ||||
|   | ||||
| @@ -6,8 +6,10 @@ import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class AppsPage extends StatefulWidget { | ||||
|   const AppsPage({super.key}); | ||||
| @@ -18,6 +20,8 @@ class AppsPage extends StatefulWidget { | ||||
|  | ||||
| class AppsPageState extends State<AppsPage> { | ||||
|   AppsFilter? filter; | ||||
|   var updatesOnlyFilter = | ||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||
|   Set<String> selectedIds = {}; | ||||
|  | ||||
|   clearSelected() { | ||||
| @@ -45,6 +49,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     var sortedApps = appsProvider.apps.values.toList(); | ||||
|     var currentFilterIsUpdatesOnly = | ||||
|         filter?.isIdenticalTo(updatesOnlyFilter) ?? false; | ||||
|  | ||||
|     selectedIds = selectedIds | ||||
|         .where((element) => sortedApps.map((e) => e.app.id).contains(element)) | ||||
| @@ -108,10 +114,23 @@ class AppsPageState extends State<AppsPage> { | ||||
|       return result; | ||||
|     }); | ||||
|  | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.descending) { | ||||
|       sortedApps = sortedApps.reversed.toList(); | ||||
|     } | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = appsProvider | ||||
|         .getExistingUpdates(installedOnly: true) | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedIds.contains(element)) | ||||
|         .toList(); | ||||
|     var newInstallIdsAllOrSelected = appsProvider | ||||
|         .getExistingUpdates(nonInstalledOnly: true) | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedIds.contains(element)) | ||||
|         .toList(); | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
| @@ -133,8 +152,9 @@ class AppsPageState extends State<AppsPage> { | ||||
|                           : Text( | ||||
|                               appsProvider.apps.isEmpty | ||||
|                                   ? 'No Apps' | ||||
|                                   : 'No Search Results', | ||||
|                                   : 'No Apps for Filter', | ||||
|                               style: Theme.of(context).textTheme.headlineMedium, | ||||
|                               textAlign: TextAlign.center, | ||||
|                             ))), | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
| @@ -154,7 +174,37 @@ class AppsPageState extends State<AppsPage> { | ||||
|                     : (sortedApps[index].app.installedVersion != null && | ||||
|                             sortedApps[index].app.installedVersion != | ||||
|                                 sortedApps[index].app.latestVersion | ||||
|                         ? const Text('Update Available') | ||||
|                         ? Column( | ||||
|                             mainAxisAlignment: MainAxisAlignment.center, | ||||
|                             crossAxisAlignment: CrossAxisAlignment.end, | ||||
|                             children: [ | ||||
|                               const Text('Update Available'), | ||||
|                               SourceProvider() | ||||
|                                           .getSource(sortedApps[index].app.url) | ||||
|                                           .changeLogPageFromStandardUrl( | ||||
|                                               sortedApps[index].app.url) == | ||||
|                                       null | ||||
|                                   ? const SizedBox() | ||||
|                                   : GestureDetector( | ||||
|                                       onTap: () { | ||||
|                                         launchUrlString( | ||||
|                                             SourceProvider() | ||||
|                                                 .getSource( | ||||
|                                                     sortedApps[index].app.url) | ||||
|                                                 .changeLogPageFromStandardUrl( | ||||
|                                                     sortedApps[index].app.url)!, | ||||
|                                             mode: | ||||
|                                                 LaunchMode.externalApplication); | ||||
|                                       }, | ||||
|                                       child: const Text( | ||||
|                                         'See Changes', | ||||
|                                         style: TextStyle( | ||||
|                                             fontStyle: FontStyle.italic, | ||||
|                                             decoration: | ||||
|                                                 TextDecoration.underline), | ||||
|                                       )), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : Text(sortedApps[index].app.installedVersion ?? | ||||
|                             'Not Installed')), | ||||
|                 onTap: () { | ||||
| @@ -175,154 +225,279 @@ class AppsPageState extends State<AppsPage> { | ||||
|       persistentFooterButtons: [ | ||||
|         Row( | ||||
|           children: [ | ||||
|             TextButton.icon( | ||||
|             IconButton( | ||||
|                 onPressed: () { | ||||
|                   selectedIds.isEmpty | ||||
|                       ? selectThese(sortedApps.map((e) => e.app.id).toList()) | ||||
|                       : clearSelected(); | ||||
|                 }, | ||||
|                 icon: Icon(selectedIds.isEmpty | ||||
|                     ? Icons.select_all_outlined | ||||
|                     : Icons.deselect_outlined), | ||||
|                 label: Text(selectedIds.isEmpty | ||||
|                 icon: Icon( | ||||
|                   selectedIds.isEmpty | ||||
|                       ? Icons.select_all_outlined | ||||
|                       : Icons.deselect_outlined, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 tooltip: selectedIds.isEmpty | ||||
|                     ? 'Select All' | ||||
|                     : 'Deselect ${selectedIds.length.toString()}')), | ||||
|                     : 'Deselect ${selectedIds.length.toString()}'), | ||||
|             const VerticalDivider(), | ||||
|             Expanded( | ||||
|                 child: selectedIds.isEmpty | ||||
|                     ? Container() | ||||
|                     : Row( | ||||
|                         mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                         children: [ | ||||
|                           IconButton( | ||||
|                             visualDensity: VisualDensity.compact, | ||||
|                             onPressed: () { | ||||
|                               showDialog<List<String>?>( | ||||
|                                   context: context, | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return GeneratedFormModal( | ||||
|                                       title: 'Remove Selected Apps?', | ||||
|                                       items: const [], | ||||
|                                       defaultValues: const [], | ||||
|                                       initValid: true, | ||||
|                                       message: | ||||
|                                           '${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.', | ||||
|                                     ); | ||||
|                                   }).then((values) { | ||||
|                                 if (values != null) { | ||||
|                                   appsProvider.removeApps(selectedIds.toList()); | ||||
|                                 } | ||||
|                               }); | ||||
|                             }, | ||||
|                             tooltip: 'Remove Selected Apps', | ||||
|                             icon: const Icon(Icons.delete_outline_outlined), | ||||
|                           ), | ||||
|                           IconButton( | ||||
|                               visualDensity: VisualDensity.compact, | ||||
|                               onPressed: appsProvider.areDownloadsRunning() || | ||||
|                                       selectedIds | ||||
|                                           .where((id) => | ||||
|                                               appsProvider.apps[id]!.app | ||||
|                                                   .installedVersion != | ||||
|                                               appsProvider | ||||
|                                                   .apps[id]!.app.latestVersion) | ||||
|                                           .isEmpty | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.heavyImpact(); | ||||
|                                       var existingUpdateIdsSelected = | ||||
|                                           appsProvider | ||||
|                                               .getExistingUpdates( | ||||
|                                                   installedOnly: true) | ||||
|                                               .where((element) => | ||||
|                                                   selectedIds.contains(element)) | ||||
|                                               .toList(); | ||||
|                                       var newInstallIdsSelected = appsProvider | ||||
|                                           .getExistingUpdates( | ||||
|                                               nonInstalledOnly: true) | ||||
|                                           .where((element) => | ||||
|                                               selectedIds.contains(element)) | ||||
|                                           .toList(); | ||||
|                                       List<List<GeneratedFormItem>> formInputs = | ||||
|                                           []; | ||||
|                                       if (existingUpdateIdsSelected | ||||
|                                               .isNotEmpty && | ||||
|                                           newInstallIdsSelected.isNotEmpty) { | ||||
|                                         formInputs.add([ | ||||
|                                           GeneratedFormItem( | ||||
|                                               label: | ||||
|                                                   'Update ${existingUpdateIdsSelected.length} Apps?', | ||||
|                                               type: FormItemType.bool) | ||||
|                                         ]); | ||||
|                                         formInputs.add([ | ||||
|                                           GeneratedFormItem( | ||||
|                                               label: | ||||
|                                                   'Install ${newInstallIdsSelected.length} new Apps?', | ||||
|                                               type: FormItemType.bool) | ||||
|                                         ]); | ||||
|                                       } | ||||
|                                       showDialog<List<String>?>( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return GeneratedFormModal( | ||||
|                                               title: 'Install Selected Apps?', | ||||
|                                               message: | ||||
|                                                   '${existingUpdateIdsSelected.length} update${existingUpdateIdsSelected.length == 1 ? '' : 's'} and ${newInstallIdsSelected.length} new install${newInstallIdsSelected.length == 1 ? '' : 's'}.', | ||||
|                                               items: formInputs, | ||||
|                                               defaultValues: const [ | ||||
|                                                 'true', | ||||
|                                                 'true' | ||||
|                                               ], | ||||
|                                               initValid: true, | ||||
|                                             ); | ||||
|                                           }).then((values) { | ||||
|                                         if (values != null) { | ||||
|                                           bool shouldInstallUpdates = | ||||
|                                               values.length < 2 || | ||||
|                                                   values[0] == 'true'; | ||||
|                                           bool shouldInstallNew = | ||||
|                                               values.length < 2 || | ||||
|                                                   values[1] == 'true'; | ||||
|                                           settingsProvider | ||||
|                                               .getInstallPermission() | ||||
|                                               .then((_) { | ||||
|                                             List<String> toInstall = []; | ||||
|                                             if (shouldInstallUpdates) { | ||||
|                                               toInstall.addAll( | ||||
|                                                   existingUpdateIdsSelected); | ||||
|                                             } | ||||
|                                             if (shouldInstallNew) { | ||||
|                                               toInstall.addAll( | ||||
|                                                   newInstallIdsSelected); | ||||
|                                             } | ||||
|                                             appsProvider | ||||
|                                                 .downloadAndInstallLatestApp( | ||||
|                                                     toInstall, context); | ||||
|                                           }); | ||||
|                                         } | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               tooltip: 'Install/Update Selected Apps', | ||||
|                               icon: const Icon( | ||||
|                                 Icons.file_download_outlined, | ||||
|                               )), | ||||
|                           IconButton( | ||||
|                             visualDensity: VisualDensity.compact, | ||||
|                             onPressed: () { | ||||
|                               String urls = ''; | ||||
|                               for (var id in selectedIds) { | ||||
|                                 urls += '${appsProvider.apps[id]!.app.url}\n'; | ||||
|                 child: Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|               children: [ | ||||
|                 selectedIds.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
|                         visualDensity: VisualDensity.compact, | ||||
|                         onPressed: () { | ||||
|                           showDialog<List<String>?>( | ||||
|                               context: context, | ||||
|                               builder: (BuildContext ctx) { | ||||
|                                 return GeneratedFormModal( | ||||
|                                   title: 'Remove Selected Apps?', | ||||
|                                   items: const [], | ||||
|                                   defaultValues: const [], | ||||
|                                   initValid: true, | ||||
|                                   message: | ||||
|                                       '${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.', | ||||
|                                 ); | ||||
|                               }).then((values) { | ||||
|                             if (values != null) { | ||||
|                               appsProvider.removeApps(selectedIds.toList()); | ||||
|                             } | ||||
|                           }); | ||||
|                         }, | ||||
|                         tooltip: 'Remove Selected Apps', | ||||
|                         icon: const Icon(Icons.delete_outline_outlined), | ||||
|                       ), | ||||
|                 IconButton( | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     onPressed: appsProvider.areDownloadsRunning() || | ||||
|                             (existingUpdateIdsAllOrSelected.isEmpty && | ||||
|                                 newInstallIdsAllOrSelected.isEmpty) | ||||
|                         ? null | ||||
|                         : () { | ||||
|                             HapticFeedback.heavyImpact(); | ||||
|                             List<List<GeneratedFormItem>> formInputs = []; | ||||
|                             if (existingUpdateIdsAllOrSelected.isNotEmpty && | ||||
|                                 newInstallIdsAllOrSelected.isNotEmpty) { | ||||
|                               formInputs.add([ | ||||
|                                 GeneratedFormItem( | ||||
|                                     label: | ||||
|                                         'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', | ||||
|                                     type: FormItemType.bool) | ||||
|                               ]); | ||||
|                               formInputs.add([ | ||||
|                                 GeneratedFormItem( | ||||
|                                     label: | ||||
|                                         'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', | ||||
|                                     type: FormItemType.bool) | ||||
|                               ]); | ||||
|                             } | ||||
|                             showDialog<List<String>?>( | ||||
|                                 context: context, | ||||
|                                 builder: (BuildContext ctx) { | ||||
|                                   return GeneratedFormModal( | ||||
|                                     title: | ||||
|                                         'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', | ||||
|                                     message: | ||||
|                                         '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', | ||||
|                                     items: formInputs, | ||||
|                                     defaultValues: const ['true', 'true'], | ||||
|                                     initValid: true, | ||||
|                                   ); | ||||
|                                 }).then((values) { | ||||
|                               if (values != null) { | ||||
|                                 bool shouldInstallUpdates = | ||||
|                                     values.length < 2 || values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = | ||||
|                                     values.length < 2 || values[1] == 'true'; | ||||
|                                 settingsProvider | ||||
|                                     .getInstallPermission() | ||||
|                                     .then((_) { | ||||
|                                   List<String> toInstall = []; | ||||
|                                   if (shouldInstallUpdates) { | ||||
|                                     toInstall | ||||
|                                         .addAll(existingUpdateIdsAllOrSelected); | ||||
|                                   } | ||||
|                                   if (shouldInstallNew) { | ||||
|                                     toInstall | ||||
|                                         .addAll(newInstallIdsAllOrSelected); | ||||
|                                   } | ||||
|                                   appsProvider.downloadAndInstallLatestApp( | ||||
|                                       toInstall, context); | ||||
|                                 }); | ||||
|                               } | ||||
|                               urls = urls.substring(0, urls.length - 1); | ||||
|                               Share.share(urls, | ||||
|                                   subject: 'Selected App URLs from Obtainium'); | ||||
|                             }, | ||||
|                             tooltip: 'Share Selected App URLs', | ||||
|                             icon: const Icon(Icons.share), | ||||
|                           ), | ||||
|                         ], | ||||
|                       )), | ||||
|                             }); | ||||
|                           }, | ||||
|                     tooltip: | ||||
|                         'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', | ||||
|                     icon: const Icon( | ||||
|                       Icons.file_download_outlined, | ||||
|                     )), | ||||
|                 selectedIds.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
|                         visualDensity: VisualDensity.compact, | ||||
|                         onPressed: () { | ||||
|                           showDialog( | ||||
|                               context: context, | ||||
|                               builder: (BuildContext ctx) { | ||||
|                                 return AlertDialog( | ||||
|                                   scrollable: true, | ||||
|                                   content: Padding( | ||||
|                                     padding: const EdgeInsets.only(top: 6), | ||||
|                                     child: Row( | ||||
|                                         mainAxisAlignment: | ||||
|                                             MainAxisAlignment.spaceBetween, | ||||
|                                         children: [ | ||||
|                                           IconButton( | ||||
|                                               onPressed: | ||||
|                                                   appsProvider | ||||
|                                                           .areDownloadsRunning() | ||||
|                                                       ? null | ||||
|                                                       : () { | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return AlertDialog( | ||||
|                                                                   title: Text( | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Not Installed?'), | ||||
|                                                                   actions: [ | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'No')), | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           HapticFeedback | ||||
|                                                                               .selectionClick(); | ||||
|                                                                           appsProvider | ||||
|                                                                               .saveApps(selectedIds.map((e) { | ||||
|                                                                             var a = | ||||
|                                                                                 appsProvider.apps[e]!.app; | ||||
|                                                                             a.installedVersion = | ||||
|                                                                                 null; | ||||
|                                                                             return a; | ||||
|                                                                           }).toList()); | ||||
|  | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'Yes')) | ||||
|                                                                   ], | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Not Installed', | ||||
|                                               icon: const Icon( | ||||
|                                                   Icons.no_cell_outlined)), | ||||
|                                           IconButton( | ||||
|                                               onPressed: | ||||
|                                                   appsProvider | ||||
|                                                           .areDownloadsRunning() | ||||
|                                                       ? null | ||||
|                                                       : () { | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return AlertDialog( | ||||
|                                                                   title: Text( | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Installed/Updated?'), | ||||
|                                                                   actions: [ | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'No')), | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           HapticFeedback | ||||
|                                                                               .selectionClick(); | ||||
|                                                                           appsProvider | ||||
|                                                                               .saveApps(selectedIds.map((e) { | ||||
|                                                                             var a = | ||||
|                                                                                 appsProvider.apps[e]!.app; | ||||
|                                                                             a.installedVersion = | ||||
|                                                                                 a.latestVersion; | ||||
|                                                                             return a; | ||||
|                                                                           }).toList()); | ||||
|  | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'Yes')) | ||||
|                                                                   ], | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Installed/Updated', | ||||
|                                               icon: const Icon(Icons.done)), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
|                                               String urls = ''; | ||||
|                                               for (var id in selectedIds) { | ||||
|                                                 urls += | ||||
|                                                     '${appsProvider.apps[id]!.app.url}\n'; | ||||
|                                               } | ||||
|                                               urls = urls.substring( | ||||
|                                                   0, urls.length - 1); | ||||
|                                               Share.share(urls, | ||||
|                                                   subject: | ||||
|                                                       '${selectedIds.length} Selected App URLs from Obtainium'); | ||||
|                                             }, | ||||
|                                             tooltip: 'Share Selected App URLs', | ||||
|                                             icon: const Icon(Icons.share), | ||||
|                                           ), | ||||
|                                         ]), | ||||
|                                   ), | ||||
|                                 ); | ||||
|                               }); | ||||
|                         }, | ||||
|                         tooltip: 'More', | ||||
|                         icon: const Icon(Icons.more_horiz), | ||||
|                       ), | ||||
|               ], | ||||
|             )), | ||||
|             const VerticalDivider(), | ||||
|             IconButton( | ||||
|               visualDensity: VisualDensity.compact, | ||||
|               onPressed: () { | ||||
|                 setState(() { | ||||
|                   if (currentFilterIsUpdatesOnly) { | ||||
|                     filter = null; | ||||
|                   } else { | ||||
|                     filter = updatesOnlyFilter; | ||||
|                   } | ||||
|                 }); | ||||
|               }, | ||||
|               tooltip: currentFilterIsUpdatesOnly | ||||
|                   ? 'Remove Out-of-Date App Filter' | ||||
|                   : 'Show Out-of-Date Apps Only', | ||||
|               icon: Icon( | ||||
|                 currentFilterIsUpdatesOnly | ||||
|                     ? Icons.update_disabled_rounded | ||||
|                     : Icons.update_rounded, | ||||
|                 color: Theme.of(context).colorScheme.primary, | ||||
|               ), | ||||
|             ), | ||||
|             appsProvider.apps.isEmpty | ||||
|                 ? const SizedBox() | ||||
|                 : TextButton.icon( | ||||
| @@ -368,10 +543,6 @@ class AppsPageState extends State<AppsPage> { | ||||
|                               filter = null; | ||||
|                             } | ||||
|                           }); | ||||
|                         } else { | ||||
|                           setState(() { | ||||
|                             filter = null; | ||||
|                           }); | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|   | ||||
| @@ -40,7 +40,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|  | ||||
|     Future<List<List<String>>> addApps(List<String> urls) async { | ||||
|       await settingsProvider.getInstallPermission(); | ||||
|       List<dynamic> results = await sourceProvider.getApps(urls); | ||||
|       List<dynamic> results = await sourceProvider.getApps(urls, | ||||
|           ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList()); | ||||
|       List<App> apps = results[0]; | ||||
|       Map<String, dynamic> errorsMap = results[1]; | ||||
|       for (var app in apps) { | ||||
| @@ -266,42 +267,67 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                       ); | ||||
|                                                     }).then((values) { | ||||
|                                                   if (values != null) { | ||||
|                                                     setState(() { | ||||
|                                                       importInProgress = true; | ||||
|                                                     }); | ||||
|                                                     source | ||||
|                                                         .getUrls(values) | ||||
|                                                         .then((urls) { | ||||
|                                                       setState(() { | ||||
|                                                         importInProgress = true; | ||||
|                                                       }); | ||||
|                                                       addApps(urls) | ||||
|                                                           .then((errors) { | ||||
|                                                         if (errors.isEmpty) { | ||||
|                                                           ScaffoldMessenger.of( | ||||
|                                                                   context) | ||||
|                                                               .showSnackBar( | ||||
|                                                             SnackBar( | ||||
|                                                                 content: Text( | ||||
|                                                                     'Imported ${urls.length} Apps')), | ||||
|                                                           ); | ||||
|                                                         } else { | ||||
|                                                           showDialog( | ||||
|                                                       showDialog<List<String>?>( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return ImportErrorDialog( | ||||
|                                                                     urlsLength: urls | ||||
|                                                                         .length, | ||||
|                                                                     errors: | ||||
|                                                                         errors); | ||||
|                                                               }); | ||||
|                                                                 return UrlSelectionModal( | ||||
|                                                                     urls: urls); | ||||
|                                                               }) | ||||
|                                                           .then((selectedUrls) { | ||||
|                                                         if (selectedUrls != | ||||
|                                                             null) { | ||||
|                                                           addApps(selectedUrls) | ||||
|                                                               .then((errors) { | ||||
|                                                             if (errors | ||||
|                                                                 .isEmpty) { | ||||
|                                                               ScaffoldMessenger | ||||
|                                                                       .of(context) | ||||
|                                                                   .showSnackBar( | ||||
|                                                                 SnackBar( | ||||
|                                                                     content: Text( | ||||
|                                                                         'Imported ${selectedUrls.length} Apps')), | ||||
|                                                               ); | ||||
|                                                             } else { | ||||
|                                                               showDialog( | ||||
|                                                                   context: | ||||
|                                                                       context, | ||||
|                                                                   builder: | ||||
|                                                                       (BuildContext | ||||
|                                                                           ctx) { | ||||
|                                                                     return ImportErrorDialog( | ||||
|                                                                         urlsLength: | ||||
|                                                                             selectedUrls | ||||
|                                                                                 .length, | ||||
|                                                                         errors: | ||||
|                                                                             errors); | ||||
|                                                                   }); | ||||
|                                                             } | ||||
|                                                           }).whenComplete(() { | ||||
|                                                             setState(() { | ||||
|                                                               importInProgress = | ||||
|                                                                   false; | ||||
|                                                             }); | ||||
|                                                           }); | ||||
|                                                         } else { | ||||
|                                                           setState(() { | ||||
|                                                             importInProgress = | ||||
|                                                                 false; | ||||
|                                                           }); | ||||
|                                                         } | ||||
|                                                       }).whenComplete(() { | ||||
|                                                         setState(() { | ||||
|                                                           importInProgress = | ||||
|                                                               false; | ||||
|                                                         }); | ||||
|                                                       }); | ||||
|                                                     }).catchError((e) { | ||||
|                                                       setState(() { | ||||
|                                                         importInProgress = | ||||
|                                                             false; | ||||
|                                                       }); | ||||
|                                                       ScaffoldMessenger.of( | ||||
|                                                               context) | ||||
|                                                           .showSnackBar( | ||||
| @@ -375,3 +401,67 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class UrlSelectionModal extends StatefulWidget { | ||||
|   UrlSelectionModal({super.key, required this.urls}); | ||||
|  | ||||
|   List<String> urls; | ||||
|  | ||||
|   @override | ||||
|   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); | ||||
| } | ||||
|  | ||||
| class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|   Map<String, bool> urlSelections = {}; | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     for (var url in widget.urls) { | ||||
|       urlSelections.putIfAbsent(url, () => true); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Select URLs to Import'), | ||||
|       content: Column(children: [ | ||||
|         ...urlSelections.keys.map((url) { | ||||
|           return Row(children: [ | ||||
|             Checkbox( | ||||
|                 value: urlSelections[url], | ||||
|                 onChanged: (value) { | ||||
|                   setState(() { | ||||
|                     urlSelections[url] = value ?? false; | ||||
|                   }); | ||||
|                 }), | ||||
|             const SizedBox( | ||||
|               width: 8, | ||||
|             ), | ||||
|             Expanded( | ||||
|                 child: Text( | ||||
|               Uri.parse(url).path.substring(1), | ||||
|             )) | ||||
|           ]); | ||||
|         }) | ||||
|       ]), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(urlSelections.keys | ||||
|                   .where((url) => urlSelections[url] ?? false) | ||||
|                   .toList()); | ||||
|             }, | ||||
|             child: Text( | ||||
|                 'Import ${urlSelections.values.where((b) => b).length} URLs')) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| @@ -15,6 +17,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } | ||||
| @@ -22,8 +25,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Settings'), | ||||
|           SliverFillRemaining( | ||||
|               hasScrollBody: true, | ||||
|           SliverToBoxAdapter( | ||||
|               child: Padding( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   child: settingsProvider.prefs == null | ||||
| @@ -160,7 +162,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               'More', | ||||
|                               'Updates', | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
| @@ -169,68 +171,108 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     labelText: | ||||
|                                         'Background Update Checking Interval'), | ||||
|                                 value: settingsProvider.updateInterval, | ||||
|                                 items: const [ | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 15, | ||||
|                                     child: Text('15 Minutes'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 30, | ||||
|                                     child: Text('30 Minutes'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 60, | ||||
|                                     child: Text('1 Hour'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 360, | ||||
|                                     child: Text('6 Hours'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 720, | ||||
|                                     child: Text('12 Hours'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 1440, | ||||
|                                     child: Text('1 Day'), | ||||
|                                   ), | ||||
|                                   DropdownMenuItem( | ||||
|                                     value: 0, | ||||
|                                     child: Text('Never - Manual Only'), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                                 items: updateIntervals.map((e) { | ||||
|                                   int displayNum = (e < 60 | ||||
|                                           ? e | ||||
|                                           : e < 1440 | ||||
|                                               ? e / 60 | ||||
|                                               : e / 1440) | ||||
|                                       .round(); | ||||
|                                   var displayUnit = (e < 60 | ||||
|                                       ? 'Minute' | ||||
|                                       : e < 1440 | ||||
|                                           ? 'Hour' | ||||
|                                           : 'Day'); | ||||
|  | ||||
|                                   String display = e == 0 | ||||
|                                       ? 'Never - Manual Only' | ||||
|                                       : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; | ||||
|                                   return DropdownMenuItem( | ||||
|                                       value: e, child: Text(display)); | ||||
|                                 }).toList(), | ||||
|                                 onChanged: (value) { | ||||
|                                   if (value != null) { | ||||
|                                     settingsProvider.updateInterval = value; | ||||
|                                   } | ||||
|                                 }), | ||||
|                             const Spacer(), | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.center, | ||||
|                               children: [ | ||||
|                                 TextButton.icon( | ||||
|                                   style: ButtonStyle( | ||||
|                                     foregroundColor: | ||||
|                                         MaterialStateProperty.resolveWith< | ||||
|                                             Color>((Set<MaterialState> states) { | ||||
|                                       return Colors.grey; | ||||
|                                     }), | ||||
|                                   ), | ||||
|                                   onPressed: () { | ||||
|                                     launchUrlString(settingsProvider.sourceUrl, | ||||
|                                         mode: LaunchMode.externalApplication); | ||||
|                                   }, | ||||
|                                   icon: const Icon(Icons.code), | ||||
|                                   label: Text( | ||||
|                                     'Source', | ||||
|                                     style: | ||||
|                                         Theme.of(context).textTheme.bodySmall, | ||||
|                                   ), | ||||
|                                 ) | ||||
|                               ], | ||||
|                             const SizedBox( | ||||
|                               height: 8, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               'Longer intervals recommended for large App collections', | ||||
|                               style: Theme.of(context) | ||||
|                                   .textTheme | ||||
|                                   .labelMedium! | ||||
|                                   .merge(const TextStyle( | ||||
|                                       fontStyle: FontStyle.italic)), | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 48, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               'Source-Specific', | ||||
|                               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(); | ||||
|                               } | ||||
|                             }), | ||||
|                           ], | ||||
|                         ))) | ||||
|                         ))), | ||||
|           SliverToBoxAdapter( | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 const SizedBox( | ||||
|                   height: 16, | ||||
|                 ), | ||||
|                 TextButton.icon( | ||||
|                   style: ButtonStyle( | ||||
|                     foregroundColor: MaterialStateProperty.resolveWith<Color>( | ||||
|                         (Set<MaterialState> states) { | ||||
|                       return Colors.grey; | ||||
|                     }), | ||||
|                   ), | ||||
|                   onPressed: () { | ||||
|                     launchUrlString(settingsProvider.sourceUrl, | ||||
|                         mode: LaunchMode.externalApplication); | ||||
|                   }, | ||||
|                   icon: const Icon(Icons.code), | ||||
|                   label: Text( | ||||
|                     'Source', | ||||
|                     style: Theme.of(context).textTheme.bodySmall, | ||||
|                   ), | ||||
|                 ), | ||||
|                 const SizedBox( | ||||
|                   height: 16, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ) | ||||
|         ])); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,13 +8,14 @@ import 'dart:io'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:flutter_fgbg/flutter_fgbg.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:flutter_install_app/flutter_install_app.dart'; | ||||
|  | ||||
| class AppInMemory { | ||||
|   late App app; | ||||
| @@ -63,6 +64,9 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<ApkFile> downloadApp(String apkUrl, String appId) async { | ||||
|     apkUrl = await SourceProvider() | ||||
|         .getSource(apps[appId]!.app.url) | ||||
|         .apkUrlPrefetchModifier(apkUrl); | ||||
|     StreamedResponse response = | ||||
|         await Client().send(Request('GET', Uri.parse(apkUrl))); | ||||
|     File downloadFile = | ||||
| @@ -124,7 +128,7 @@ class AppsProvider with ChangeNotifier { | ||||
|   // 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(ApkFile file) async { | ||||
|     await AppInstaller.installApk(file.file.path, actionRequired: false); | ||||
|     await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium'); | ||||
|     apps[file.appId]!.app.installedVersion = | ||||
|         apps[file.appId]!.app.latestVersion; | ||||
|     await saveApps([apps[file.appId]!.app]); | ||||
| @@ -194,14 +198,38 @@ class AppsProvider with ChangeNotifier { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     for (var u in silentUpdates) { | ||||
|       await installApk(u); | ||||
|     // If Obtainium is being installed, it should be the last one | ||||
|     List<ApkFile> moveObtainiumToEnd(List<ApkFile> items) { | ||||
|       String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; | ||||
|       ApkFile? temp; | ||||
|       items.removeWhere((element) { | ||||
|         bool res = element.appId == obtainiumId; | ||||
|         if (res) { | ||||
|           temp = element; | ||||
|         } | ||||
|         return res; | ||||
|       }); | ||||
|       if (temp != null) { | ||||
|         items.add(temp!); | ||||
|       } | ||||
|       return items; | ||||
|     } | ||||
|  | ||||
|     // TODO: Remove below line if silentupdates are ever figured out | ||||
|     regularInstalls.addAll(silentUpdates); | ||||
|  | ||||
|     silentUpdates = moveObtainiumToEnd(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToEnd(regularInstalls); | ||||
|  | ||||
|     // TODO: Uncomment below if silentupdates are ever figured out | ||||
|     // for (var u in silentUpdates) { | ||||
|     //   await installApk(u, silent: true); // Would need to add silent option | ||||
|     // } | ||||
|  | ||||
|     if (context != null) { | ||||
|       if (regularInstalls.isNotEmpty) { | ||||
|         // ignore: use_build_context_synchronously | ||||
|         await askUserToReturnToForeground(context); | ||||
|         await askUserToReturnToForeground(context, waitForFG: true); | ||||
|       } | ||||
|       for (var i in regularInstalls) { | ||||
|         await installApk(i); | ||||
| @@ -285,32 +313,59 @@ class AppsProvider with ChangeNotifier { | ||||
|     App newApp = await sourceProvider.getApp( | ||||
|         sourceProvider.getSource(currentApp.url), | ||||
|         currentApp.url, | ||||
|         currentApp.additionalData); | ||||
|     if (newApp.latestVersion != currentApp.latestVersion) { | ||||
|       newApp.installedVersion = currentApp.installedVersion; | ||||
|       if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|         newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|       } | ||||
|       await saveApps([newApp]); | ||||
|       return newApp; | ||||
|         currentApp.additionalData, | ||||
|         customName: currentApp.name); | ||||
|     newApp.installedVersion = currentApp.installedVersion; | ||||
|     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|     } | ||||
|     return null; | ||||
|     await saveApps([newApp]); | ||||
|     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||
|   } | ||||
|  | ||||
|   Future<List<App>> checkUpdates() async { | ||||
|   Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async { | ||||
|     List<App> updates = []; | ||||
|     Map<String, List<String>> errors = {}; | ||||
|     if (!gettingUpdates) { | ||||
|       gettingUpdates = true; | ||||
|  | ||||
|       List<String> appIds = apps.keys.toList(); | ||||
|       if (ignoreAfter != null) { | ||||
|         appIds = appIds | ||||
|             .where((id) => | ||||
|                 apps[id]!.app.lastUpdateCheck == null || | ||||
|                 apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) | ||||
|             .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 = await getUpdate(appIds[i]); | ||||
|         App? newApp; | ||||
|         try { | ||||
|           newApp = await getUpdate(appIds[i]); | ||||
|         } catch (e) { | ||||
|           var tempIds = errors.remove(e.toString()); | ||||
|           tempIds ??= []; | ||||
|           tempIds.add(appIds[i]); | ||||
|           errors.putIfAbsent(e.toString(), () => tempIds!); | ||||
|         } | ||||
|         if (newApp != null) { | ||||
|           updates.add(newApp); | ||||
|         } | ||||
|       } | ||||
|       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; | ||||
|     } | ||||
|     return updates; | ||||
|   } | ||||
|  | ||||
| @@ -355,8 +410,8 @@ class AppsProvider with ChangeNotifier { | ||||
|     for (App a in importedApps) { | ||||
|       a.installedVersion = | ||||
|           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; | ||||
|       await saveApps([a]); | ||||
|     } | ||||
|     await saveApps(importedApps); | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|   } | ||||
| @@ -391,7 +446,10 @@ class _APKPickerState extends State<APKPicker> { | ||||
|         Text('${widget.app.name} has more than one package:'), | ||||
|         const SizedBox(height: 16), | ||||
|         ...widget.app.apkUrls.map((u) => RadioListTile<String>( | ||||
|             title: Text(Uri.parse(u).pathSegments.last), | ||||
|             title: Text(Uri.parse(u) | ||||
|                 .pathSegments | ||||
|                 .where((element) => element.isNotEmpty) | ||||
|                 .last), | ||||
|             value: u, | ||||
|             groupValue: apkUrl, | ||||
|             onChanged: (String? val) { | ||||
|   | ||||
| @@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName } | ||||
|  | ||||
| enum SortOrderSettings { ascending, descending } | ||||
|  | ||||
| const maxAPIRateLimitMinutes = 30; | ||||
| const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30; | ||||
| const maxUpdateIntervalMinutes = 4320; | ||||
| List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0] | ||||
|     .where((element) => | ||||
|         (element >= minUpdateIntervalMinutes && | ||||
|             element <= maxUpdateIntervalMinutes) || | ||||
|         element == 0) | ||||
|     .toList(); | ||||
|  | ||||
| class SettingsProvider with ChangeNotifier { | ||||
|   SharedPreferences? prefs; | ||||
|  | ||||
| @@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   int get updateInterval { | ||||
|     return prefs?.getInt('updateInterval') ?? 1440; | ||||
|     var min = prefs?.getInt('updateInterval') ?? 180; | ||||
|     if (!updateIntervals.contains(min)) { | ||||
|       var temp = updateIntervals[0]; | ||||
|       for (var i in updateIntervals) { | ||||
|         if (min > i && i != 0) { | ||||
|           temp = i; | ||||
|         } | ||||
|       } | ||||
|       min = temp; | ||||
|     } | ||||
|     return min; | ||||
|   } | ||||
|  | ||||
|   set updateInterval(int min) { | ||||
| @@ -54,8 +74,8 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   SortColumnSettings get sortColumn { | ||||
|     return SortColumnSettings | ||||
|         .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; | ||||
|     return SortColumnSettings.values[ | ||||
|         prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; | ||||
|   } | ||||
|  | ||||
|   set sortColumn(SortColumnSettings s) { | ||||
| @@ -65,7 +85,7 @@ class SettingsProvider with ChangeNotifier { | ||||
|  | ||||
|   SortOrderSettings get sortOrder { | ||||
|     return SortOrderSettings.values[ | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; | ||||
|   } | ||||
|  | ||||
|   set sortOrder(SortOrderSettings s) { | ||||
| @@ -95,11 +115,20 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   bool get showAppWebpage { | ||||
|     return prefs?.getBool('showAppWebpage') ?? true; | ||||
|     return prefs?.getBool('showAppWebpage') ?? false; | ||||
|   } | ||||
|  | ||||
|   set showAppWebpage(bool show) { | ||||
|     prefs?.setBool('showAppWebpage', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|   } | ||||
|  | ||||
|   void setSettingString(String settingId, String value) { | ||||
|     prefs?.setString(settingId, value); | ||||
|     notifyListeners(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:obtainium/app_sources/apkmirror.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/app_sources/gitlab.dart'; | ||||
| @@ -38,6 +39,7 @@ class App { | ||||
|   List<String> apkUrls = []; | ||||
|   late int preferredApkIndex; | ||||
|   late List<String> additionalData; | ||||
|   late DateTime? lastUpdateCheck; | ||||
|   App( | ||||
|       this.id, | ||||
|       this.url, | ||||
| @@ -47,7 +49,8 @@ class App { | ||||
|       this.latestVersion, | ||||
|       this.apkUrls, | ||||
|       this.preferredApkIndex, | ||||
|       this.additionalData); | ||||
|       this.additionalData, | ||||
|       this.lastUpdateCheck); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
| @@ -69,7 +72,10 @@ class App { | ||||
|       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, | ||||
|       json['additionalData'] == null | ||||
|           ? SourceProvider().getSource(json['url']).additionalDataDefaults | ||||
|           : List<String>.from(jsonDecode(json['additionalData']))); | ||||
|           : List<String>.from(jsonDecode(json['additionalData'])), | ||||
|       json['lastUpdateCheck'] == null | ||||
|           ? null | ||||
|           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); | ||||
|  | ||||
|   Map<String, dynamic> toJson() => { | ||||
|         'id': id, | ||||
| @@ -80,7 +86,8 @@ class App { | ||||
|         'latestVersion': latestVersion, | ||||
|         'apkUrls': jsonEncode(apkUrls), | ||||
|         'preferredApkIndex': preferredApkIndex, | ||||
|         'additionalData': jsonEncode(additionalData) | ||||
|         'additionalData': jsonEncode(additionalData), | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch | ||||
|       }; | ||||
| } | ||||
|  | ||||
| @@ -90,7 +97,7 @@ escapeRegEx(String s) { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| makeUrlHttps(String url) { | ||||
| preStandardizeUrl(String url) { | ||||
|   if (url.toLowerCase().indexOf('http://') != 0 && | ||||
|       url.toLowerCase().indexOf('https://') != 0) { | ||||
|     url = 'https://$url'; | ||||
| @@ -129,6 +136,9 @@ abstract class AppSource { | ||||
|   AppNames getAppNames(String standardUrl); | ||||
|   late List<List<GeneratedFormItem>> additionalDataFormItems; | ||||
|   late List<String> additionalDataDefaults; | ||||
|   late List<GeneratedFormItem> moreSourceSettingsFormItems; | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl); | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl); | ||||
| } | ||||
|  | ||||
| abstract class MassAppSource { | ||||
| @@ -146,14 +156,15 @@ class SourceProvider { | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     SourceForge() | ||||
|     SourceForge(), | ||||
|     APKMirror() | ||||
|   ]; | ||||
|  | ||||
|   // Add more mass source classes here so they are available via the service | ||||
|   List<MassAppSource> massSources = [GitHubStars()]; | ||||
|  | ||||
|   AppSource getSource(String url) { | ||||
|     url = makeUrlHttps(url); | ||||
|     url = preStandardizeUrl(url); | ||||
|     AppSource? source; | ||||
|     for (var s in sources) { | ||||
|       if (url.toLowerCase().contains('://${s.host}')) { | ||||
| @@ -180,7 +191,7 @@ class SourceProvider { | ||||
|  | ||||
|   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||
|       {String customName = ''}) async { | ||||
|     String standardUrl = source.standardizeURL(makeUrlHttps(url)); | ||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||
|     AppNames names = source.getAppNames(standardUrl); | ||||
|     APKDetails apk = | ||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||
| @@ -195,15 +206,17 @@ class SourceProvider { | ||||
|         apk.version, | ||||
|         apk.apkUrls, | ||||
|         apk.apkUrls.length - 1, | ||||
|         additionalData); | ||||
|         additionalData, | ||||
|         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 | ||||
|   Future<List<dynamic>> getApps(List<String> urls) async { | ||||
|   Future<List<dynamic>> getApps(List<String> urls, | ||||
|       {List<String> ignoreUrls = const []}) async { | ||||
|     List<App> apps = []; | ||||
|     Map<String, dynamic> errors = {}; | ||||
|     for (var url in urls) { | ||||
|     for (var url in urls.where((element) => !ignoreUrls.contains(element))) { | ||||
|       try { | ||||
|         var source = getSource(url); | ||||
|         apps.add(await getApp(source, url, source.additionalDataDefaults)); | ||||
|   | ||||
							
								
								
									
										67
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -7,7 +7,7 @@ packages: | ||||
|       name: animations | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.5" | ||||
|     version: "2.0.7" | ||||
|   archive: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -71,6 +71,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.16.0" | ||||
|   cross_file: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: cross_file | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.3.3+2" | ||||
|   crypto: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -105,42 +112,42 @@ packages: | ||||
|       name: device_info_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.1.2" | ||||
|     version: "5.0.5" | ||||
|   device_info_plus_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "4.0.2" | ||||
|   device_info_plus_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_macos | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "4.0.2" | ||||
|   device_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "4.0.1" | ||||
|   device_info_plus_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_web | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "4.0.2" | ||||
|   device_info_plus_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.1.0" | ||||
|     version: "5.0.2" | ||||
|   dynamic_color: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -175,7 +182,7 @@ packages: | ||||
|       name: file_picker | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.2.0" | ||||
|     version: "5.2.0+1" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -188,13 +195,6 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|   flutter_install_app: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_install_app | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   flutter_launcher_icons: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -215,7 +215,7 @@ packages: | ||||
|       name: flutter_local_notifications | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "11.0.1" | ||||
|     version: "12.0.0" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -282,6 +282,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.2.0" | ||||
|   install_plugin_v2: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: install_plugin_v2 | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   js: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -386,7 +393,7 @@ packages: | ||||
|       name: path_provider_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.4" | ||||
|     version: "2.0.5" | ||||
|   path_provider_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -400,35 +407,35 @@ packages: | ||||
|       name: permission_handler | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.1" | ||||
|     version: "10.1.0" | ||||
|   permission_handler_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|     version: "10.1.0" | ||||
|   permission_handler_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "9.0.4" | ||||
|     version: "9.0.6" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.7.1" | ||||
|     version: "3.9.0" | ||||
|   permission_handler_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|     version: "0.1.1" | ||||
|   petitparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -470,7 +477,7 @@ packages: | ||||
|       name: share_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.4.0" | ||||
|     version: "4.5.3" | ||||
|   share_plus_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -491,14 +498,14 @@ packages: | ||||
|       name: share_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.3" | ||||
|     version: "3.1.1" | ||||
|   share_plus_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_web | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|     version: "3.1.0" | ||||
|   share_plus_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -629,7 +636,7 @@ packages: | ||||
|       name: url_launcher | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.1.5" | ||||
|     version: "6.1.6" | ||||
|   url_launcher_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -664,7 +671,7 @@ packages: | ||||
|       name: url_launcher_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|     version: "2.1.1" | ||||
|   url_launcher_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -699,21 +706,21 @@ packages: | ||||
|       name: webview_flutter_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.10.2" | ||||
|     version: "2.10.4" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.3" | ||||
|     version: "1.9.5" | ||||
|   webview_flutter_wkwebview: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_wkwebview | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.9.4" | ||||
|     version: "2.9.5" | ||||
|   win32: | ||||
|     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.4.0+19 # When changing this, update the tag in main() accordingly | ||||
| version: 0.5.9+30 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||
| @@ -38,7 +38,7 @@ dependencies: | ||||
|   cupertino_icons: ^1.0.5 | ||||
|   path_provider: ^2.0.11 | ||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||
|   flutter_local_notifications: ^11.0.1 | ||||
|   flutter_local_notifications: ^12.0.0 | ||||
|   provider: ^6.0.3 | ||||
|   http: ^0.13.5 | ||||
|   webview_flutter: ^3.0.4 | ||||
| @@ -49,10 +49,10 @@ dependencies: | ||||
|   url_launcher: ^6.1.5 | ||||
|   permission_handler: ^10.0.0 | ||||
|   fluttertoast: ^8.0.9 | ||||
|   device_info_plus: ^4.1.2 | ||||
|   device_info_plus: ^5.0.5 | ||||
|   file_picker: ^5.1.0 | ||||
|   animations: ^2.0.4 | ||||
|   flutter_install_app: ^1.3.0 | ||||
|   install_plugin_v2: ^1.0.0 | ||||
|   share_plus: ^4.4.0 | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import 'package:obtainium/main.dart'; | ||||
| void main() { | ||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||
|     // Build our app and trigger a frame. | ||||
|     await tester.pumpWidget(const MyApp()); | ||||
|     await tester.pumpWidget(const Obtainium()); | ||||
|  | ||||
|     // Verify that our counter starts at 0. | ||||
|     expect(find.text('0'), findsOneWidget); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user