mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-30 21:13:28 +01:00 
			
		
		
		
	Compare commits
	
		
			44 Commits
		
	
	
		
			v0.3.2-bet
			...
			v0.5.10-be
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | a954a627fd | ||
|  | 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 | ||
|  | 136628c9e6 | ||
|  | a916167be3 | ||
|  | 420cf487d4 | ||
|  | 12855370b0 | ||
|  | 33fed1cb2f | ||
|  | 33238b56a9 | ||
|  | 428c208de4 | ||
|  | 9a4b0301be | ||
|  | f58d26524c | ||
|  | 45e5544c5b | ||
|  | 0a9373e65a | ||
|  | b65c6e1d41 | 
| @@ -13,6 +13,7 @@ Currently supported App sources: | |||||||
| - [IzzyOnDroid](https://android.izzysoft.de/) | - [IzzyOnDroid](https://android.izzysoft.de/) | ||||||
| - [Mullvad](https://mullvad.net/en/) | - [Mullvad](https://mullvad.net/en/) | ||||||
| - [Signal](https://signal.org/) | - [Signal](https://signal.org/) | ||||||
|  | - [APKMirror](https://apkmirror.com/) | ||||||
|  |  | ||||||
| ## Limitations | ## Limitations | ||||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								android/app/src/main/res/xml/file_paths.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								android/app/src/main/res/xml/file_paths.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <paths> | ||||||
|  |     <external-path path="Android/data/dev.imranr.obtainium/" name="files_root" /> | ||||||
|  |     <external-path path="." name="external_storage_root" /> | ||||||
|  | </paths> | ||||||
							
								
								
									
										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 |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); |     RegExp standardUrlRegExB = | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |         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) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw notValidURL(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -54,4 +66,7 @@ class FDroid implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = []; |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,7 +1,11 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
| import 'package:http/http.dart'; | import 'package:http/http.dart'; | ||||||
| import 'package:obtainium/components/generated_form.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:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class GitHub implements AppSource { | class GitHub implements AppSource { | ||||||
|   @override |   @override | ||||||
| @@ -17,18 +21,33 @@ class GitHub implements AppSource { | |||||||
|     return url.substring(0, match.end); |     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 |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
|     var includePrereleases = |     var includePrereleases = | ||||||
|         additionalData.isNotEmpty && additionalData[0] == "true"; |         additionalData.isNotEmpty && additionalData[0] == 'true'; | ||||||
|     var fallbackToOlderReleases = |     var fallbackToOlderReleases = | ||||||
|         additionalData.length >= 2 && additionalData[1] == "true"; |         additionalData.length >= 2 && additionalData[1] == 'true'; | ||||||
|     var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty |     var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty | ||||||
|         ? additionalData[2] |         ? additionalData[2] | ||||||
|         : null; |         : null; | ||||||
|     Response res = await get(Uri.parse( |     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) { |     if (res.statusCode == 200) { | ||||||
|       var releases = jsonDecode(res.body) as List<dynamic>; |       var releases = jsonDecode(res.body) as List<dynamic>; | ||||||
|  |  | ||||||
| @@ -76,7 +95,10 @@ class GitHub implements AppSource { | |||||||
|       return APKDetails(version, targetRelease['apkUrls']); |       return APKDetails(version, targetRelease['apkUrls']); | ||||||
|     } else { |     } else { | ||||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { |       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; |       throw couldNotFindReleases; | ||||||
| @@ -92,14 +114,14 @@ class GitHub implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = [ |   List<List<GeneratedFormItem>> additionalDataFormItems = [ | ||||||
|     [GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)], |     [GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], | ||||||
|     [ |     [ | ||||||
|       GeneratedFormItem( |       GeneratedFormItem( | ||||||
|           label: "Fallback to older releases", type: FormItemType.bool) |           label: 'Fallback to older releases', type: FormItemType.bool) | ||||||
|     ], |     ], | ||||||
|     [ |     [ | ||||||
|       GeneratedFormItem( |       GeneratedFormItem( | ||||||
|           label: "Filter Release Titles by Regular Expression", |           label: 'Filter Release Titles by Regular Expression', | ||||||
|           type: FormItemType.string, |           type: FormItemType.string, | ||||||
|           required: false, |           required: false, | ||||||
|           additionalValidators: [ |           additionalValidators: [ | ||||||
| @@ -110,7 +132,7 @@ class GitHub implements AppSource { | |||||||
|               try { |               try { | ||||||
|                 RegExp(value); |                 RegExp(value); | ||||||
|               } catch (e) { |               } catch (e) { | ||||||
|                 return "Invalid regular expression"; |                 return 'Invalid regular expression'; | ||||||
|               } |               } | ||||||
|               return null; |               return null; | ||||||
|             } |             } | ||||||
| @@ -119,5 +141,44 @@ class GitHub implements AppSource { | |||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = ["true", "true", ""]; |   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); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|  |       '$standardUrl/-/releases'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -68,4 +75,7 @@ class GitLab implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = []; |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,12 @@ class IzzyOnDroid implements AppSource { | |||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -62,4 +68,7 @@ class IzzyOnDroid implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = []; |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,6 +17,13 @@ class Mullvad implements AppSource { | |||||||
|     return url.substring(0, match.end); |     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 |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -48,4 +55,7 @@ class Mullvad implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = []; |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,6 +12,12 @@ class Signal implements AppSource { | |||||||
|     return 'https://$host'; |     return 'https://$host'; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -41,4 +47,7 @@ class Signal implements AppSource { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<String> additionalDataDefaults = []; |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										77
									
								
								lib/app_sources/sourceforge.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								lib/app_sources/sourceforge.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | |||||||
|  | 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 SourceForge implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'sourceforge.net'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.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 { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/rss?path=/')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var parsedHtml = parse(res.body); | ||||||
|  |       var allDownloadLinks = | ||||||
|  |           parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList(); | ||||||
|  |       getVersion(String url) { | ||||||
|  |         try { | ||||||
|  |           var tokens = url.split('/'); | ||||||
|  |           return tokens[tokens.length - 3]; | ||||||
|  |         } catch (e) { | ||||||
|  |           return null; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       String? version = getVersion(allDownloadLinks[0]); | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       var apkUrlListAllReleases = allDownloadLinks | ||||||
|  |           .where((element) => element.toLowerCase().endsWith('.apk/download')) | ||||||
|  |           .toList(); | ||||||
|  |       var apkUrlList = | ||||||
|  |           apkUrlListAllReleases // This can be used skipped for fallback support later | ||||||
|  |               .where((element) => getVersion(element) == version) | ||||||
|  |               .toList(); | ||||||
|  |       if (apkUrlList.isEmpty) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, apkUrlList); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames(runtimeType.toString(), | ||||||
|  |         standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<String> additionalDataDefaults = []; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
|  | } | ||||||
| @@ -10,13 +10,19 @@ class GeneratedFormItem { | |||||||
|   late bool required; |   late bool required; | ||||||
|   late int max; |   late int max; | ||||||
|   late List<String? Function(String? value)> additionalValidators; |   late List<String? Function(String? value)> additionalValidators; | ||||||
|  |   late String id; | ||||||
|  |   late List<Widget> belowWidgets; | ||||||
|  |   late String? hint; | ||||||
|  |  | ||||||
|   GeneratedFormItem( |   GeneratedFormItem( | ||||||
|       {this.label = "Input", |       {this.label = 'Input', | ||||||
|       this.type = FormItemType.string, |       this.type = FormItemType.string, | ||||||
|       this.required = true, |       this.required = true, | ||||||
|       this.max = 1, |       this.max = 1, | ||||||
|       this.additionalValidators = const []}); |       this.additionalValidators = const [], | ||||||
|  |       this.id = 'input', | ||||||
|  |       this.belowWidgets = const [], | ||||||
|  |       this.hint}); | ||||||
| } | } | ||||||
|  |  | ||||||
| class GeneratedForm extends StatefulWidget { | class GeneratedForm extends StatefulWidget { | ||||||
| @@ -69,7 +75,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|         .map((row) => row.map((e) { |         .map((row) => row.map((e) { | ||||||
|               return j < widget.defaultValues.length |               return j < widget.defaultValues.length | ||||||
|                   ? widget.defaultValues[j++] |                   ? widget.defaultValues[j++] | ||||||
|                   : ""; |                   : ''; | ||||||
|             }).toList()) |             }).toList()) | ||||||
|         .toList(); |         .toList(); | ||||||
|  |  | ||||||
| @@ -89,7 +95,8 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|               }); |               }); | ||||||
|             }, |             }, | ||||||
|             decoration: InputDecoration( |             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, |             minLines: e.value.max <= 1 ? null : e.value.max, | ||||||
|             maxLines: e.value.max <= 1 ? 1 : e.value.max, |             maxLines: e.value.max <= 1 ? 1 : e.value.max, | ||||||
|             validator: (value) { |             validator: (value) { | ||||||
| @@ -122,10 +129,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|             children: [ |             children: [ | ||||||
|               Text(widget.items[r][e].label), |               Text(widget.items[r][e].label), | ||||||
|               Switch( |               Switch( | ||||||
|                   value: values[r][e] == "true", |                   value: values[r][e] == 'true', | ||||||
|                   onChanged: (value) { |                   onChanged: (value) { | ||||||
|                     setState(() { |                     setState(() { | ||||||
|                       values[r][e] = value ? "true" : ""; |                       values[r][e] = value ? 'true' : ''; | ||||||
|                       someValueChanged(); |                       someValueChanged(); | ||||||
|                     }); |                     }); | ||||||
|                   }) |                   }) | ||||||
| @@ -155,7 +162,13 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|             width: 20, |             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); |       rows.add(rowItems); | ||||||
|     }); |     }); | ||||||
|   | |||||||
| @@ -7,11 +7,15 @@ class GeneratedFormModal extends StatefulWidget { | |||||||
|       {super.key, |       {super.key, | ||||||
|       required this.title, |       required this.title, | ||||||
|       required this.items, |       required this.items, | ||||||
|       required this.defaultValues}); |       required this.defaultValues, | ||||||
|  |       this.initValid = false, | ||||||
|  |       this.message = ''}); | ||||||
|  |  | ||||||
|   final String title; |   final String title; | ||||||
|  |   final String message; | ||||||
|   final List<List<GeneratedFormItem>> items; |   final List<List<GeneratedFormItem>> items; | ||||||
|   final List<String> defaultValues; |   final List<String> defaultValues; | ||||||
|  |   final bool initValid; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); |   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||||
| @@ -21,20 +25,34 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | |||||||
|   List<String> values = []; |   List<String> values = []; | ||||||
|   bool valid = false; |   bool valid = false; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     valid = widget.initValid; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return AlertDialog( |     return AlertDialog( | ||||||
|       scrollable: true, |       scrollable: true, | ||||||
|       title: Text(widget.title), |       title: Text(widget.title), | ||||||
|       content: GeneratedForm( |       content: | ||||||
|           items: widget.items, |           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||||
|           onValueChanges: (values, valid) { |         if (widget.message.isNotEmpty) Text(widget.message), | ||||||
|             setState(() { |         if (widget.message.isNotEmpty) | ||||||
|               this.values = values; |           const SizedBox( | ||||||
|               this.valid = valid; |             height: 16, | ||||||
|             }); |           ), | ||||||
|           }, |         GeneratedForm( | ||||||
|           defaultValues: widget.defaultValues), |             items: widget.items, | ||||||
|  |             onValueChanges: (values, valid) { | ||||||
|  |               setState(() { | ||||||
|  |                 this.values = values; | ||||||
|  |                 this.valid = valid; | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             defaultValues: widget.defaultValues) | ||||||
|  |       ]), | ||||||
|       actions: [ |       actions: [ | ||||||
|         TextButton( |         TextButton( | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|   | |||||||
							
								
								
									
										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'; | ||||||
|  | } | ||||||
							
								
								
									
										160
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										160
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/app_sources/github.dart'; | import 'package:obtainium/app_sources/github.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/home.dart'; | import 'package:obtainium/pages/home.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/notifications_provider.dart'; | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
| @@ -13,33 +14,81 @@ import 'package:dynamic_color/dynamic_color.dart'; | |||||||
| import 'package:device_info_plus/device_info_plus.dart'; | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
|  |  | ||||||
| const String currentReleaseTag = | const String currentReleaseTag = | ||||||
|     'v0.3.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES |     'v0.5.10-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(); | ||||||
|  |     String? err; | ||||||
|  |     try { | ||||||
|  |       await appsProvider.checkUpdates( | ||||||
|  |           ignoreAfter: ignoreAfter, immediatelyThrowRateLimitError: true); | ||||||
|  |     } 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 { | ||||||
|  |         err = e.toString(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     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); | ||||||
|  |     } | ||||||
|  |     if (err != null) { | ||||||
|  |       throw err; | ||||||
|  |     } | ||||||
|  |     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') | @pragma('vm:entry-point') | ||||||
| void bgTaskCallback() { | void bgTaskCallback() { | ||||||
|   // Background update checking process |   // Background process callback | ||||||
|   Workmanager().executeTask((task, taskName) async { |   Workmanager().executeTask((task, inputData) async { | ||||||
|     var notificationsProvider = NotificationsProvider(); |     return await bgUpdateCheck(inputData?['ignoreAfter']); | ||||||
|     await notificationsProvider.notify(checkingUpdatesNotification); |  | ||||||
|     try { |  | ||||||
|       var appsProvider = AppsProvider(); |  | ||||||
|       await notificationsProvider |  | ||||||
|           .cancel(ErrorCheckingUpdatesNotification('').id); |  | ||||||
|       await appsProvider.loadApps(); |  | ||||||
|       List<App> updates = await appsProvider.checkUpdates(); |  | ||||||
|       if (updates.isNotEmpty) { |  | ||||||
|         notificationsProvider.notify(UpdateNotification(updates), |  | ||||||
|             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); |  | ||||||
|     } |  | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -64,14 +113,21 @@ void main() async { | |||||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), |       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||||
|       Provider(create: (context) => NotificationsProvider()) |       Provider(create: (context) => NotificationsProvider()) | ||||||
|     ], |     ], | ||||||
|     child: const MyApp(), |     child: const Obtainium(), | ||||||
|   )); |   )); | ||||||
| } | } | ||||||
|  |  | ||||||
| var defaultThemeColour = Colors.deepPurple; | var defaultThemeColour = Colors.deepPurple; | ||||||
|  |  | ||||||
| class MyApp extends StatelessWidget { | class Obtainium extends StatefulWidget { | ||||||
|   const MyApp({super.key}); |   const Obtainium({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<Obtainium> createState() => _ObtainiumState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ObtainiumState extends State<Obtainium> { | ||||||
|  |   var existingUpdateInterval = -1; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
| @@ -81,30 +137,40 @@ class MyApp extends StatelessWidget { | |||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } else { |     } 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(); |       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||||
|       if (isFirstRun) { |       if (isFirstRun) { | ||||||
|         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list |         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||||
|         Permission.notification.request(); |         Permission.notification.request(); | ||||||
|         appsProvider.saveApp(App( |         appsProvider.saveApps([ | ||||||
|             'imranr98_obtainium_${GitHub().host}', |           App( | ||||||
|             'https://github.com/ImranR98/Obtainium', |               'imranr98_obtainium_${GitHub().host}', | ||||||
|             'ImranR98', |               'https://github.com/ImranR98/Obtainium', | ||||||
|             'Obtainium', |               'ImranR98', | ||||||
|             currentReleaseTag, |               'Obtainium', | ||||||
|             currentReleaseTag, |               currentReleaseTag, | ||||||
|             [], |               currentReleaseTag, | ||||||
|             0, |               [], | ||||||
|             ["true"])); |               0, | ||||||
|  |               ['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)); | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:http/http.dart'; | 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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class GitHubStars implements MassAppSource { | class GitHubStars implements MassAppSource { | ||||||
| @@ -10,23 +12,40 @@ class GitHubStars implements MassAppSource { | |||||||
|   @override |   @override | ||||||
|   late List<String> requiredArgs = ['Username']; |   late List<String> requiredArgs = ['Username']; | ||||||
|  |  | ||||||
|   @override |   Future<List<String>> getOnePageOfUserStarredUrls( | ||||||
|   Future<List<String>> getUrls(List<String> args) async { |       String username, int page) async { | ||||||
|     if (args.length != requiredArgs.length) { |     Response res = await get(Uri.parse( | ||||||
|       throw 'Wrong number of arguments provided'; |         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); | ||||||
|     } |  | ||||||
|     Response res = |  | ||||||
|         await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); |  | ||||||
|     if (res.statusCode == 200) { |     if (res.statusCode == 200) { | ||||||
|       return (jsonDecode(res.body) as List<dynamic>) |       return (jsonDecode(res.body) as List<dynamic>) | ||||||
|           .map((e) => e['html_url'] as String) |           .map((e) => e['html_url'] as String) | ||||||
|           .toList(); |           .toList(); | ||||||
|     } else { |     } else { | ||||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { |       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'; |       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; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -19,9 +19,10 @@ class AddAppPage extends StatefulWidget { | |||||||
| class _AddAppPageState extends State<AddAppPage> { | class _AddAppPageState extends State<AddAppPage> { | ||||||
|   bool gettingAppInfo = false; |   bool gettingAppInfo = false; | ||||||
|  |  | ||||||
|   String userInput = ""; |   String userInput = ''; | ||||||
|   AppSource? pickedSource; |   AppSource? pickedSource; | ||||||
|   List<String> additionalData = []; |   List<String> additionalData = []; | ||||||
|  |   String customName = ''; | ||||||
|   bool validAdditionalData = true; |   bool validAdditionalData = true; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -44,19 +45,19 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                   items: [ |                                   items: [ | ||||||
|                                     [ |                                     [ | ||||||
|                                       GeneratedFormItem( |                                       GeneratedFormItem( | ||||||
|                                           label: "App Source Url", |                                           label: 'App Source Url', | ||||||
|                                           additionalValidators: [ |                                           additionalValidators: [ | ||||||
|                                             (value) { |                                             (value) { | ||||||
|                                               try { |                                               try { | ||||||
|                                                 sourceProvider |                                                 sourceProvider | ||||||
|                                                     .getSource(value ?? "") |                                                     .getSource(value ?? '') | ||||||
|                                                     .standardizeURL( |                                                     .standardizeURL( | ||||||
|                                                         makeUrlHttps( |                                                         preStandardizeUrl( | ||||||
|                                                             value ?? "")); |                                                             value ?? '')); | ||||||
|                                               } catch (e) { |                                               } catch (e) { | ||||||
|                                                 return e is String |                                                 return e is String | ||||||
|                                                     ? e |                                                     ? e | ||||||
|                                                     : "Error"; |                                                     : 'Error'; | ||||||
|                                               } |                                               } | ||||||
|                                               return null; |                                               return null; | ||||||
|                                             } |                                             } | ||||||
| @@ -79,6 +80,9 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                                 .doesSourceHaveRequiredAdditionalData( |                                                 .doesSourceHaveRequiredAdditionalData( | ||||||
|                                                     source) |                                                     source) | ||||||
|                                             : true; |                                             : true; | ||||||
|  |                                         if (source == null) { | ||||||
|  |                                           customName = ''; | ||||||
|  |                                         } | ||||||
|                                       } |                                       } | ||||||
|                                     }); |                                     }); | ||||||
|                                   }, |                                   }, | ||||||
| @@ -100,7 +104,8 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                       }); |                                       }); | ||||||
|                                       sourceProvider |                                       sourceProvider | ||||||
|                                           .getApp(pickedSource!, userInput, |                                           .getApp(pickedSource!, userInput, | ||||||
|                                               additionalData) |                                               additionalData, | ||||||
|  |                                               customName: customName) | ||||||
|                                           .then((app) { |                                           .then((app) { | ||||||
|                                         var appsProvider = |                                         var appsProvider = | ||||||
|                                             context.read<AppsProvider>(); |                                             context.read<AppsProvider>(); | ||||||
| @@ -113,7 +118,8 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                         settingsProvider |                                         settingsProvider | ||||||
|                                             .getInstallPermission() |                                             .getInstallPermission() | ||||||
|                                             .then((_) { |                                             .then((_) { | ||||||
|                                           appsProvider.saveApp(app).then((_) { |                                           appsProvider | ||||||
|  |                                               .saveApps([app]).then((_) { | ||||||
|                                             Navigator.push( |                                             Navigator.push( | ||||||
|                                                 context, |                                                 context, | ||||||
|                                                 MaterialPageRoute( |                                                 MaterialPageRoute( | ||||||
| @@ -136,8 +142,7 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                               child: const Text('Add')) |                               child: const Text('Add')) | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|                       if (pickedSource != null && |                       if (pickedSource != null) | ||||||
|                           (pickedSource!.additionalDataFormItems.isNotEmpty)) |  | ||||||
|                         Column( |                         Column( | ||||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, |                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                           children: [ |                           children: [ | ||||||
| @@ -152,16 +157,38 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                             const SizedBox( |                             const SizedBox( | ||||||
|                               height: 16, |                               height: 16, | ||||||
|                             ), |                             ), | ||||||
|                             GeneratedForm( |                             if (pickedSource! | ||||||
|                                 items: pickedSource!.additionalDataFormItems, |                                 .additionalDataFormItems.isNotEmpty) | ||||||
|                                 onValueChanges: (values, valid) { |                               GeneratedForm( | ||||||
|                                   setState(() { |                                   items: pickedSource!.additionalDataFormItems, | ||||||
|                                     additionalData = values; |                                   onValueChanges: (values, valid) { | ||||||
|                                     validAdditionalData = valid; |                                     setState(() { | ||||||
|                                   }); |                                       additionalData = values; | ||||||
|                                 }, |                                       validAdditionalData = valid; | ||||||
|                                 defaultValues: |                                     }); | ||||||
|                                     pickedSource!.additionalDataDefaults) |                                   }, | ||||||
|  |                                   defaultValues: | ||||||
|  |                                       pickedSource!.additionalDataDefaults), | ||||||
|  |                             if (pickedSource! | ||||||
|  |                                 .additionalDataFormItems.isNotEmpty) | ||||||
|  |                               const SizedBox( | ||||||
|  |                                 height: 8, | ||||||
|  |                               ), | ||||||
|  |                             if (pickedSource != null) | ||||||
|  |                               GeneratedForm( | ||||||
|  |                                   items: [ | ||||||
|  |                                     [ | ||||||
|  |                                       GeneratedFormItem( | ||||||
|  |                                           label: 'Custom App Name', | ||||||
|  |                                           required: false) | ||||||
|  |                                     ] | ||||||
|  |                                   ], | ||||||
|  |                                   onValueChanges: (values, valid) { | ||||||
|  |                                     setState(() { | ||||||
|  |                                       customName = values[0]; | ||||||
|  |                                     }); | ||||||
|  |                                   }, | ||||||
|  |                                   defaultValues: [customName]) | ||||||
|                           ], |                           ], | ||||||
|                         ) |                         ) | ||||||
|                       else |                       else | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form.dart'; | ||||||
| import 'package:obtainium/components/generated_form_modal.dart'; | import 'package:obtainium/components/generated_form_modal.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| @@ -18,71 +19,102 @@ class AppPage extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _AppPageState extends State<AppPage> { | class _AppPageState extends State<AppPage> { | ||||||
|  |   AppInMemory? prevApp; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     var settingsProvider = context.watch<SettingsProvider>(); |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     getUpdate(String id) { | ||||||
|  |       appsProvider.getUpdate(id).catchError((e) { | ||||||
|  |         ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |           SnackBar(content: Text(e.toString())), | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     var sourceProvider = SourceProvider(); |     var sourceProvider = SourceProvider(); | ||||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; |     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||||
|     var source = app != null ? sourceProvider.getSource(app.app.url) : null; |     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||||
|     if (app?.app.installedVersion != null) { |     if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { | ||||||
|       appsProvider.getUpdate(app!.app.id); |       prevApp = app; | ||||||
|  |       getUpdate(app.app.id); | ||||||
|     } |     } | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|       appBar: settingsProvider.showAppWebpage ? AppBar() : null, |       appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||||
|       backgroundColor: Theme.of(context).colorScheme.surface, |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|       body: settingsProvider.showAppWebpage |       body: RefreshIndicator( | ||||||
|           ? WebView( |           child: settingsProvider.showAppWebpage | ||||||
|               initialUrl: app?.app.url, |               ? WebView( | ||||||
|               javascriptMode: JavascriptMode.unrestricted, |                   initialUrl: app?.app.url, | ||||||
|             ) |                   javascriptMode: JavascriptMode.unrestricted, | ||||||
|           : Column( |                 ) | ||||||
|               mainAxisAlignment: MainAxisAlignment.center, |               : CustomScrollView( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.stretch, |                   slivers: [ | ||||||
|               children: [ |                     SliverFillRemaining( | ||||||
|                 Text( |                         child: Column( | ||||||
|                   app?.app.name ?? 'App', |                       mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                   textAlign: TextAlign.center, |                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                   style: Theme.of(context).textTheme.displayLarge, |                       children: [ | ||||||
|                 ), |                         Text( | ||||||
|                 Text( |                           app?.app.name ?? 'App', | ||||||
|                   'By ${app?.app.author ?? 'Unknown'}', |                           textAlign: TextAlign.center, | ||||||
|                   textAlign: TextAlign.center, |                           style: Theme.of(context).textTheme.displayLarge, | ||||||
|                   style: Theme.of(context).textTheme.headlineMedium, |                         ), | ||||||
|                 ), |                         Text( | ||||||
|                 const SizedBox( |                           'By ${app?.app.author ?? 'Unknown'}', | ||||||
|                   height: 32, |                           textAlign: TextAlign.center, | ||||||
|                 ), |                           style: Theme.of(context).textTheme.headlineMedium, | ||||||
|                 GestureDetector( |                         ), | ||||||
|                     onTap: () { |                         const SizedBox( | ||||||
|                       if (app?.app.url != null) { |                           height: 32, | ||||||
|                         launchUrlString(app?.app.url ?? '', |                         ), | ||||||
|                             mode: LaunchMode.externalApplication); |                         GestureDetector( | ||||||
|                       } |                             onTap: () { | ||||||
|                     }, |                               if (app?.app.url != null) { | ||||||
|                     child: Text( |                                 launchUrlString(app?.app.url ?? '', | ||||||
|                       app?.app.url ?? '', |                                     mode: LaunchMode.externalApplication); | ||||||
|                       textAlign: TextAlign.center, |                               } | ||||||
|                       style: const TextStyle( |                             }, | ||||||
|                           decoration: TextDecoration.underline, |                             child: Text( | ||||||
|                           fontStyle: FontStyle.italic, |                               app?.app.url ?? '', | ||||||
|                           fontSize: 12), |                               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( |           onRefresh: () async { | ||||||
|                   'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', |             if (app != null) { | ||||||
|                   textAlign: TextAlign.center, |               getUpdate(app.app.id); | ||||||
|                   style: Theme.of(context).textTheme.bodyLarge, |             } | ||||||
|                 ), |           }), | ||||||
|                 Text( |  | ||||||
|                   'Installed Version: ${app?.app.installedVersion ?? 'None'}', |  | ||||||
|                   textAlign: TextAlign.center, |  | ||||||
|                   style: Theme.of(context).textTheme.bodyLarge, |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|       bottomSheet: Padding( |       bottomSheet: Padding( | ||||||
|           padding: EdgeInsets.fromLTRB( |           padding: EdgeInsets.fromLTRB( | ||||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), |               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||||
| @@ -96,104 +128,131 @@ class _AppPageState extends State<AppPage> { | |||||||
|                       children: [ |                       children: [ | ||||||
|                         if (app?.app.installedVersion != app?.app.latestVersion) |                         if (app?.app.installedVersion != app?.app.latestVersion) | ||||||
|                           IconButton( |                           IconButton( | ||||||
|                               onPressed: () { |                               onPressed: app?.downloadProgress != null | ||||||
|                                 showDialog( |                                   ? null | ||||||
|                                     context: context, |                                   : () { | ||||||
|                                     builder: (BuildContext ctx) { |                                       showDialog( | ||||||
|                                       return AlertDialog( |                                           context: context, | ||||||
|                                         title: Text( |                                           builder: (BuildContext ctx) { | ||||||
|                                             'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), |                                             return AlertDialog( | ||||||
|                                         actions: [ |                                               title: Text( | ||||||
|                                           TextButton( |                                                   'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), | ||||||
|                                               onPressed: () { |                                               actions: [ | ||||||
|                                                 Navigator.of(context).pop(); |                                                 TextButton( | ||||||
|                                               }, |                                                     onPressed: () { | ||||||
|                                               child: const Text('No')), |                                                       Navigator.of(context) | ||||||
|                                           TextButton( |                                                           .pop(); | ||||||
|                                               onPressed: () { |                                                     }, | ||||||
|                                                 HapticFeedback.selectionClick(); |                                                     child: const Text('No')), | ||||||
|                                                 var updatedApp = app?.app; |                                                 TextButton( | ||||||
|                                                 if (updatedApp != null) { |                                                     onPressed: () { | ||||||
|                                                   updatedApp.installedVersion = |                                                       HapticFeedback | ||||||
|                                                       updatedApp.latestVersion; |                                                           .selectionClick(); | ||||||
|                                                   appsProvider |                                                       var updatedApp = app?.app; | ||||||
|                                                       .saveApp(updatedApp); |                                                       if (updatedApp != null) { | ||||||
|                                                 } |                                                         updatedApp | ||||||
|                                                 Navigator.of(context).pop(); |                                                                 .installedVersion = | ||||||
|                                               }, |                                                             updatedApp | ||||||
|                                               child: const Text( |                                                                 .latestVersion; | ||||||
|                                                   'Yes, Mark as Installed')) |                                                         appsProvider.saveApps( | ||||||
|                                         ], |                                                             [updatedApp]); | ||||||
|                                       ); |                                                       } | ||||||
|                                     }); |                                                       Navigator.of(context) | ||||||
|                               }, |                                                           .pop(); | ||||||
|  |                                                     }, | ||||||
|  |                                                     child: const Text( | ||||||
|  |                                                         'Yes, Mark as Installed')) | ||||||
|  |                                               ], | ||||||
|  |                                             ); | ||||||
|  |                                           }); | ||||||
|  |                                     }, | ||||||
|                               tooltip: 'Mark as Installed', |                               tooltip: 'Mark as Installed', | ||||||
|                               icon: const Icon(Icons.done)) |                               icon: const Icon(Icons.done)) | ||||||
|                         else |                         else | ||||||
|                           IconButton( |                           IconButton( | ||||||
|                               onPressed: () { |                               onPressed: app?.downloadProgress != null | ||||||
|                                 showDialog( |                                   ? null | ||||||
|                                     context: context, |                                   : () { | ||||||
|                                     builder: (BuildContext ctx) { |                                       showDialog( | ||||||
|                                       return AlertDialog( |                                           context: context, | ||||||
|                                         title: const Text('App Not Installed?'), |                                           builder: (BuildContext ctx) { | ||||||
|                                         actions: [ |                                             return AlertDialog( | ||||||
|                                           TextButton( |                                               title: const Text( | ||||||
|                                               onPressed: () { |                                                   'App Not Installed?'), | ||||||
|                                                 Navigator.of(context).pop(); |                                               actions: [ | ||||||
|                                               }, |                                                 TextButton( | ||||||
|                                               child: const Text('No')), |                                                     onPressed: () { | ||||||
|                                           TextButton( |                                                       Navigator.of(context) | ||||||
|                                               onPressed: () { |                                                           .pop(); | ||||||
|                                                 HapticFeedback.selectionClick(); |                                                     }, | ||||||
|                                                 var updatedApp = app?.app; |                                                     child: const Text('No')), | ||||||
|                                                 if (updatedApp != null) { |                                                 TextButton( | ||||||
|                                                   updatedApp.installedVersion = |                                                     onPressed: () { | ||||||
|                                                       null; |                                                       HapticFeedback | ||||||
|                                                   appsProvider |                                                           .selectionClick(); | ||||||
|                                                       .saveApp(updatedApp); |                                                       var updatedApp = app?.app; | ||||||
|                                                 } |                                                       if (updatedApp != null) { | ||||||
|                                                 Navigator.of(context).pop(); |                                                         updatedApp | ||||||
|                                               }, |                                                                 .installedVersion = | ||||||
|                                               child: const Text( |                                                             null; | ||||||
|                                                   'Yes, Mark as Not Installed')) |                                                         appsProvider.saveApps( | ||||||
|                                         ], |                                                             [updatedApp]); | ||||||
|                                       ); |                                                       } | ||||||
|                                     }); |                                                       Navigator.of(context) | ||||||
|                               }, |                                                           .pop(); | ||||||
|  |                                                     }, | ||||||
|  |                                                     child: const Text( | ||||||
|  |                                                         'Yes, Mark as Not Installed')) | ||||||
|  |                                               ], | ||||||
|  |                                             ); | ||||||
|  |                                           }); | ||||||
|  |                                     }, | ||||||
|                               tooltip: 'Mark as Not Installed', |                               tooltip: 'Mark as Not Installed', | ||||||
|                               icon: const Icon(Icons.no_cell_outlined)), |                               icon: const Icon(Icons.no_cell_outlined)), | ||||||
|                         if (source != null && |                         if (source != null && | ||||||
|                             source.additionalDataFormItems.isNotEmpty) |                             source.additionalDataFormItems.isNotEmpty) | ||||||
|                           IconButton( |                           IconButton( | ||||||
|                               onPressed: () { |                               onPressed: app?.downloadProgress != null | ||||||
|                                 showDialog( |                                   ? null | ||||||
|                                     context: context, |                                   : () { | ||||||
|                                     builder: (BuildContext ctx) { |                                       showDialog<List<String>>( | ||||||
|                                       return GeneratedFormModal( |                                           context: context, | ||||||
|                                           title: 'Additional Options', |                                           builder: (BuildContext ctx) { | ||||||
|                                           items: source.additionalDataFormItems, |                                             return GeneratedFormModal( | ||||||
|                                           defaultValues: app != null |                                                 title: 'Additional Options', | ||||||
|                                               ? app.app.additionalData |                                                 items: [ | ||||||
|                                               : source.additionalDataDefaults); |                                                   ...source | ||||||
|                                     }).then((values) { |                                                       .additionalDataFormItems, | ||||||
|                                   if (app != null && values != null) { |                                                   [ | ||||||
|                                     var changedApp = app.app; |                                                     GeneratedFormItem( | ||||||
|                                     changedApp.additionalData = values; |                                                         label: 'App Name', | ||||||
|                                     sourceProvider |                                                         required: true) | ||||||
|                                         .getApp(source, changedApp.url, |                                                   ] | ||||||
|                                             changedApp.additionalData) |                                                 ], | ||||||
|                                         .then((finalChangedApp) { |                                                 defaultValues: app != null | ||||||
|                                       appsProvider.saveApp(finalChangedApp); |                                                     ? [ | ||||||
|                                     }).catchError((e) { |                                                         ...app | ||||||
|                                       ScaffoldMessenger.of(context) |                                                             .app.additionalData, | ||||||
|                                           .showSnackBar( |                                                         app.app.name | ||||||
|                                         SnackBar(content: Text(e.toString())), |                                                       ] | ||||||
|                                       ); |                                                     : [ | ||||||
|                                     }); |                                                         ...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]).then((value) { | ||||||
|  |                                             getUpdate(changedApp.id); | ||||||
|  |                                           }); | ||||||
|  |                                         } | ||||||
|  |                                       }); | ||||||
|  |                                     }, | ||||||
|  |                               tooltip: 'Additional Options', | ||||||
|                               icon: const Icon(Icons.settings)), |                               icon: const Icon(Icons.settings)), | ||||||
|                         const SizedBox(width: 16.0), |                         const SizedBox(width: 16.0), | ||||||
|                         Expanded( |                         Expanded( | ||||||
| @@ -209,7 +268,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                             .downloadAndInstallLatestApp( |                                             .downloadAndInstallLatestApp( | ||||||
|                                                 [app!.app.id], |                                                 [app!.app.id], | ||||||
|                                                 context).then((res) { |                                                 context).then((res) { | ||||||
|                                           if (res && mounted) { |                                           if (res.isNotEmpty && mounted) { | ||||||
|                                             Navigator.of(context).pop(); |                                             Navigator.of(context).pop(); | ||||||
|                                           } |                                           } | ||||||
|                                         }); |                                         }); | ||||||
| @@ -235,9 +294,8 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                                 onPressed: () { |                                                 onPressed: () { | ||||||
|                                                   HapticFeedback |                                                   HapticFeedback | ||||||
|                                                       .selectionClick(); |                                                       .selectionClick(); | ||||||
|                                                   appsProvider |                                                   appsProvider.removeApps( | ||||||
|                                                       .removeApp(app!.app.id) |                                                       [app!.app.id]).then((_) { | ||||||
|                                                       .then((_) { |  | ||||||
|                                                     int count = 0; |                                                     int count = 0; | ||||||
|                                                     Navigator.of(context) |                                                     Navigator.of(context) | ||||||
|                                                         .popUntil((_) => |                                                         .popUntil((_) => | ||||||
|   | |||||||
| @@ -6,29 +6,74 @@ import 'package:obtainium/components/generated_form_modal.dart'; | |||||||
| import 'package:obtainium/pages/app.dart'; | import 'package:obtainium/pages/app.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/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 { | class AppsPage extends StatefulWidget { | ||||||
|   const AppsPage({super.key}); |   const AppsPage({super.key}); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<AppsPage> createState() => _AppsPageState(); |   State<AppsPage> createState() => AppsPageState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _AppsPageState extends State<AppsPage> { | class AppsPageState extends State<AppsPage> { | ||||||
|   AppsFilter? filter; |   AppsFilter? filter; | ||||||
|  |   var updatesOnlyFilter = | ||||||
|  |       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||||
|  |   Set<String> selectedIds = {}; | ||||||
|  |  | ||||||
|  |   clearSelected() { | ||||||
|  |     if (selectedIds.isNotEmpty) { | ||||||
|  |       setState(() { | ||||||
|  |         selectedIds.clear(); | ||||||
|  |       }); | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   selectThese(List<String> appIds) { | ||||||
|  |     if (selectedIds.isEmpty) { | ||||||
|  |       setState(() { | ||||||
|  |         for (var a in appIds) { | ||||||
|  |           selectedIds.add(a); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     var settingsProvider = context.watch<SettingsProvider>(); |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|     var existingUpdateAppIds = appsProvider.getExistingUpdates(); |  | ||||||
|     var sortedApps = appsProvider.apps.values.toList(); |     var sortedApps = appsProvider.apps.values.toList(); | ||||||
|  |     var currentFilterIsUpdatesOnly = | ||||||
|  |         filter?.isIdenticalTo(updatesOnlyFilter) ?? false; | ||||||
|  |  | ||||||
|  |     selectedIds = selectedIds | ||||||
|  |         .where((element) => sortedApps.map((e) => e.app.id).contains(element)) | ||||||
|  |         .toSet(); | ||||||
|  |  | ||||||
|  |     toggleAppSelected(String appId) { | ||||||
|  |       setState(() { | ||||||
|  |         if (selectedIds.contains(appId)) { | ||||||
|  |           selectedIds.remove(appId); | ||||||
|  |         } else { | ||||||
|  |           selectedIds.add(appId); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (filter != null) { |     if (filter != null) { | ||||||
|       sortedApps = sortedApps.where((app) { |       sortedApps = sortedApps.where((app) { | ||||||
|         if (app.app.installedVersion == app.app.latestVersion && |         if (app.app.installedVersion == app.app.latestVersion && | ||||||
|             filter!.onlyNonLatest) { |             !(filter!.includeUptodate)) { | ||||||
|  |           return false; | ||||||
|  |         } | ||||||
|  |         if (app.app.installedVersion == null && | ||||||
|  |             !(filter!.includeNonInstalled)) { | ||||||
|           return false; |           return false; | ||||||
|         } |         } | ||||||
|         if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { |         if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { | ||||||
| @@ -69,141 +114,477 @@ class _AppsPageState extends State<AppsPage> { | |||||||
|       return result; |       return result; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { |     if (settingsProvider.sortOrder == SortOrderSettings.descending) { | ||||||
|       sortedApps = sortedApps.reversed.toList(); |       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( |     return Scaffold( | ||||||
|         backgroundColor: Theme.of(context).colorScheme.surface, |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|         floatingActionButton: |       body: RefreshIndicator( | ||||||
|             Row(mainAxisAlignment: MainAxisAlignment.end, children: [ |           onRefresh: () { | ||||||
|           existingUpdateAppIds.isEmpty || filter != null |             HapticFeedback.lightImpact(); | ||||||
|               ? const SizedBox() |             return appsProvider.checkUpdates().catchError((e) { | ||||||
|               : ElevatedButton.icon( |               ScaffoldMessenger.of(context).showSnackBar( | ||||||
|                   onPressed: appsProvider.areDownloadsRunning() |                 SnackBar(content: Text(e.toString())), | ||||||
|                       ? null |               ); | ||||||
|                       : () { |             }); | ||||||
|                           HapticFeedback.heavyImpact(); |           }, | ||||||
|                           settingsProvider.getInstallPermission().then((_) { |           child: CustomScrollView(slivers: <Widget>[ | ||||||
|                             appsProvider.downloadAndInstallLatestApp( |             const CustomAppBar(title: 'Apps'), | ||||||
|                                 existingUpdateAppIds, context); |             if (appsProvider.loadingApps || sortedApps.isEmpty) | ||||||
|                           }); |               SliverFillRemaining( | ||||||
|                         }, |                   child: Center( | ||||||
|                   icon: const Icon(Icons.install_mobile_outlined), |                       child: appsProvider.loadingApps | ||||||
|                   label: const Text('Install All')), |                           ? const CircularProgressIndicator() | ||||||
|           const SizedBox( |                           : Text( | ||||||
|             width: 16, |                               appsProvider.apps.isEmpty | ||||||
|           ), |                                   ? 'No Apps' | ||||||
|           appsProvider.apps.isEmpty |                                   : 'No Apps for Filter', | ||||||
|               ? const SizedBox() |                               style: Theme.of(context).textTheme.headlineMedium, | ||||||
|               : ElevatedButton.icon( |                               textAlign: TextAlign.center, | ||||||
|                   onPressed: () { |                             ))), | ||||||
|                     showDialog<List<String>?>( |             SliverList( | ||||||
|                         context: context, |                 delegate: SliverChildBuilderDelegate( | ||||||
|                         builder: (BuildContext ctx) { |                     (BuildContext context, int index) { | ||||||
|                           return GeneratedFormModal( |               return ListTile( | ||||||
|                               title: 'Filter Apps', |                 selectedTileColor: | ||||||
|                               items: [ |                     Theme.of(context).colorScheme.primary.withOpacity(0.1), | ||||||
|                                 [ |                 selected: selectedIds.contains(sortedApps[index].app.id), | ||||||
|                                   GeneratedFormItem( |                 onLongPress: () { | ||||||
|                                       label: "App Name", required: false), |                   toggleAppSelected(sortedApps[index].app.id); | ||||||
|                                   GeneratedFormItem( |                 }, | ||||||
|                                       label: "Author", required: false) |                 title: Text(sortedApps[index].app.name), | ||||||
|                                 ], |                 subtitle: Text('By ${sortedApps[index].app.author}'), | ||||||
|                                 [ |                 trailing: sortedApps[index].downloadProgress != null | ||||||
|                                   GeneratedFormItem( |                     ? Text( | ||||||
|                                       label: "Ignore Up-to-Date Apps", |                         'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||||
|                                       type: FormItemType.bool) |                     : (sortedApps[index].app.installedVersion != null && | ||||||
|                                 ] |                             sortedApps[index].app.installedVersion != | ||||||
|                               ], |                                 sortedApps[index].app.latestVersion | ||||||
|                               defaultValues: filter == null |                         ? Column( | ||||||
|                                   ? [] |                             mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                                   : [ |                             crossAxisAlignment: CrossAxisAlignment.end, | ||||||
|                                       filter!.nameFilter, |                             children: [ | ||||||
|                                       filter!.authorFilter, |                               const Text('Update Available'), | ||||||
|                                       filter!.onlyNonLatest ? 'true' : '' |                               SourceProvider() | ||||||
|                                     ]); |                                           .getSource(sortedApps[index].app.url) | ||||||
|                         }).then((values) { |                                           .changeLogPageFromStandardUrl( | ||||||
|                       if (values != null && |                                               sortedApps[index].app.url) == | ||||||
|                           values |                                       null | ||||||
|                               .where((element) => element.isNotEmpty) |                                   ? const SizedBox() | ||||||
|                               .isNotEmpty) { |                                   : GestureDetector( | ||||||
|                         setState(() { |                                       onTap: () { | ||||||
|                           filter = AppsFilter( |                                         launchUrlString( | ||||||
|                               nameFilter: values[0], |                                             SourceProvider() | ||||||
|                               authorFilter: values[1], |                                                 .getSource( | ||||||
|                               onlyNonLatest: values[2] == "true"); |                                                     sortedApps[index].app.url) | ||||||
|                         }); |                                                 .changeLogPageFromStandardUrl( | ||||||
|                       } else { |                                                     sortedApps[index].app.url)!, | ||||||
|                         setState(() { |                                             mode: | ||||||
|                           filter = null; |                                                 LaunchMode.externalApplication); | ||||||
|                         }); |                                       }, | ||||||
|                       } |                                       child: const Text( | ||||||
|                     }); |                                         'See Changes', | ||||||
|                   }, |                                         style: TextStyle( | ||||||
|                   label: Text(filter == null ? 'Search' : 'Modify Search'), |                                             fontStyle: FontStyle.italic, | ||||||
|                   icon: Icon( |                                             decoration: | ||||||
|                       filter == null ? Icons.search : Icons.manage_search)), |                                                 TextDecoration.underline), | ||||||
|         ]), |                                       )), | ||||||
|         body: RefreshIndicator( |                             ], | ||||||
|             onRefresh: () { |                           ) | ||||||
|               HapticFeedback.lightImpact(); |                         : Text(sortedApps[index].app.installedVersion ?? | ||||||
|               return appsProvider.checkUpdates().catchError((e) { |                             'Not Installed')), | ||||||
|                 ScaffoldMessenger.of(context).showSnackBar( |                 onTap: () { | ||||||
|                   SnackBar(content: Text(e.toString())), |                   if (selectedIds.isNotEmpty) { | ||||||
|                 ); |                     toggleAppSelected(sortedApps[index].app.id); | ||||||
|               }); |                   } else { | ||||||
|             }, |  | ||||||
|             child: CustomScrollView(slivers: <Widget>[ |  | ||||||
|               const CustomAppBar(title: 'Apps'), |  | ||||||
|               if (appsProvider.loadingApps || sortedApps.isEmpty) |  | ||||||
|                 SliverFillRemaining( |  | ||||||
|                     child: Center( |  | ||||||
|                         child: appsProvider.loadingApps |  | ||||||
|                             ? const CircularProgressIndicator() |  | ||||||
|                             : Text( |  | ||||||
|                                 appsProvider.apps.isEmpty |  | ||||||
|                                     ? 'No Apps' |  | ||||||
|                                     : 'No Search Results', |  | ||||||
|                                 style: |  | ||||||
|                                     Theme.of(context).textTheme.headlineMedium, |  | ||||||
|                               ))), |  | ||||||
|               SliverList( |  | ||||||
|                   delegate: SliverChildBuilderDelegate( |  | ||||||
|                       (BuildContext context, int index) { |  | ||||||
|                 return ListTile( |  | ||||||
|                   title: Text(sortedApps[index].app.name), |  | ||||||
|                   subtitle: Text('By ${sortedApps[index].app.author}'), |  | ||||||
|                   trailing: sortedApps[index].downloadProgress != null |  | ||||||
|                       ? Text( |  | ||||||
|                           'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') |  | ||||||
|                       : (sortedApps[index].app.installedVersion != null && |  | ||||||
|                               sortedApps[index].app.installedVersion != |  | ||||||
|                                   sortedApps[index].app.latestVersion |  | ||||||
|                           ? const Text('Update Available') |  | ||||||
|                           : Text(sortedApps[index].app.installedVersion ?? |  | ||||||
|                               'Not Installed')), |  | ||||||
|                   onTap: () { |  | ||||||
|                     Navigator.push( |                     Navigator.push( | ||||||
|                       context, |                       context, | ||||||
|                       MaterialPageRoute( |                       MaterialPageRoute( | ||||||
|                           builder: (context) => |                           builder: (context) => | ||||||
|                               AppPage(appId: sortedApps[index].app.id)), |                               AppPage(appId: sortedApps[index].app.id)), | ||||||
|                     ); |                     ); | ||||||
|                   }, |                   } | ||||||
|                 ); |                 }, | ||||||
|               }, childCount: sortedApps.length)) |               ); | ||||||
|             ]))); |             }, childCount: sortedApps.length)) | ||||||
|  |           ])), | ||||||
|  |       persistentFooterButtons: [ | ||||||
|  |         Row( | ||||||
|  |           children: [ | ||||||
|  |             IconButton( | ||||||
|  |                 onPressed: () { | ||||||
|  |                   selectedIds.isEmpty | ||||||
|  |                       ? selectThese(sortedApps.map((e) => e.app.id).toList()) | ||||||
|  |                       : clearSelected(); | ||||||
|  |                 }, | ||||||
|  |                 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()}'), | ||||||
|  |             const VerticalDivider(), | ||||||
|  |             Expanded( | ||||||
|  |                 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); | ||||||
|  |                                 }); | ||||||
|  |                               } | ||||||
|  |                             }); | ||||||
|  |                           }, | ||||||
|  |                     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( | ||||||
|  |                     label: Text( | ||||||
|  |                       filter == null ? 'Filter' : 'Filter *', | ||||||
|  |                       style: TextStyle( | ||||||
|  |                           fontWeight: filter == null | ||||||
|  |                               ? FontWeight.normal | ||||||
|  |                               : FontWeight.bold), | ||||||
|  |                     ), | ||||||
|  |                     onPressed: () { | ||||||
|  |                       showDialog<List<String>?>( | ||||||
|  |                           context: context, | ||||||
|  |                           builder: (BuildContext ctx) { | ||||||
|  |                             return GeneratedFormModal( | ||||||
|  |                                 title: 'Filter Apps', | ||||||
|  |                                 items: [ | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: 'App Name', required: false), | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: 'Author', required: false) | ||||||
|  |                                   ], | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: 'Up to Date Apps', | ||||||
|  |                                         type: FormItemType.bool) | ||||||
|  |                                   ], | ||||||
|  |                                   [ | ||||||
|  |                                     GeneratedFormItem( | ||||||
|  |                                         label: 'Non-Installed Apps', | ||||||
|  |                                         type: FormItemType.bool) | ||||||
|  |                                   ] | ||||||
|  |                                 ], | ||||||
|  |                                 defaultValues: filter == null | ||||||
|  |                                     ? AppsFilter().toValuesArray() | ||||||
|  |                                     : filter!.toValuesArray()); | ||||||
|  |                           }).then((values) { | ||||||
|  |                         if (values != null) { | ||||||
|  |                           setState(() { | ||||||
|  |                             filter = AppsFilter.fromValuesArray(values); | ||||||
|  |                             if (AppsFilter().isIdenticalTo(filter!)) { | ||||||
|  |                               filter = null; | ||||||
|  |                             } | ||||||
|  |                           }); | ||||||
|  |                         } | ||||||
|  |                       }); | ||||||
|  |                     }, | ||||||
|  |                     icon: const Icon(Icons.filter_list_rounded)) | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class AppsFilter { | class AppsFilter { | ||||||
|   late String nameFilter; |   late String nameFilter; | ||||||
|   late String authorFilter; |   late String authorFilter; | ||||||
|   late bool onlyNonLatest; |   late bool includeUptodate; | ||||||
|  |   late bool includeNonInstalled; | ||||||
|  |  | ||||||
|   AppsFilter( |   AppsFilter( | ||||||
|       {this.nameFilter = "", |       {this.nameFilter = '', | ||||||
|       this.authorFilter = "", |       this.authorFilter = '', | ||||||
|       this.onlyNonLatest = false}); |       this.includeUptodate = true, | ||||||
|  |       this.includeNonInstalled = true}); | ||||||
|  |  | ||||||
|  |   List<String> toValuesArray() { | ||||||
|  |     return [ | ||||||
|  |       nameFilter, | ||||||
|  |       authorFilter, | ||||||
|  |       includeUptodate ? 'true' : '', | ||||||
|  |       includeNonInstalled ? 'true' : '' | ||||||
|  |     ]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   AppsFilter.fromValuesArray(List<String> values) { | ||||||
|  |     nameFilter = values[0]; | ||||||
|  |     authorFilter = values[1]; | ||||||
|  |     includeUptodate = values[2] == 'true'; | ||||||
|  |     includeNonInstalled = values[3] == 'true'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool isIdenticalTo(AppsFilter other) => | ||||||
|  |       authorFilter.trim() == other.authorFilter.trim() && | ||||||
|  |       nameFilter.trim() == other.nameFilter.trim() && | ||||||
|  |       includeUptodate == other.includeUptodate && | ||||||
|  |       includeNonInstalled == other.includeNonInstalled; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,7 +25,8 @@ class _HomePageState extends State<HomePage> { | |||||||
|   List<int> selectedIndexHistory = []; |   List<int> selectedIndexHistory = []; | ||||||
|  |  | ||||||
|   List<NavigationPageItem> pages = [ |   List<NavigationPageItem> pages = [ | ||||||
|     NavigationPageItem('Apps', Icons.apps, const AppsPage()), |     NavigationPageItem( | ||||||
|  |         'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())), | ||||||
|     NavigationPageItem('Add App', Icons.add, const AddAppPage()), |     NavigationPageItem('Add App', Icons.add, const AddAppPage()), | ||||||
|     NavigationPageItem( |     NavigationPageItem( | ||||||
|         'Import/Export', Icons.import_export, const ImportExportPage()), |         'Import/Export', Icons.import_export, const ImportExportPage()), | ||||||
| @@ -88,7 +89,10 @@ class _HomePageState extends State<HomePage> { | |||||||
|             }); |             }); | ||||||
|             return false; |             return false; | ||||||
|           } |           } | ||||||
|           return true; |           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||||
|  |               .currentState | ||||||
|  |               ?.clearSelected(); | ||||||
|  |           // return !appsPageKey.currentState?.clearSelected(); | ||||||
|         }); |         }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -40,14 +40,15 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|  |  | ||||||
|     Future<List<List<String>>> addApps(List<String> urls) async { |     Future<List<List<String>>> addApps(List<String> urls) async { | ||||||
|       await settingsProvider.getInstallPermission(); |       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]; |       List<App> apps = results[0]; | ||||||
|       Map<String, dynamic> errorsMap = results[1]; |       Map<String, dynamic> errorsMap = results[1]; | ||||||
|       for (var app in apps) { |       for (var app in apps) { | ||||||
|         if (appsProvider.apps.containsKey(app.id)) { |         if (appsProvider.apps.containsKey(app.id)) { | ||||||
|           errorsMap.addAll({app.id: 'App already added'}); |           errorsMap.addAll({app.id: 'App already added'}); | ||||||
|         } else { |         } else { | ||||||
|           await appsProvider.saveApp(app); |           await appsProvider.saveApps([app]); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       List<List<String>> errors = |       List<List<String>> errors = | ||||||
| @@ -266,42 +267,67 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                                       ); |                                                       ); | ||||||
|                                                     }).then((values) { |                                                     }).then((values) { | ||||||
|                                                   if (values != null) { |                                                   if (values != null) { | ||||||
|  |                                                     setState(() { | ||||||
|  |                                                       importInProgress = true; | ||||||
|  |                                                     }); | ||||||
|                                                     source |                                                     source | ||||||
|                                                         .getUrls(values) |                                                         .getUrls(values) | ||||||
|                                                         .then((urls) { |                                                         .then((urls) { | ||||||
|                                                       setState(() { |                                                       showDialog<List<String>?>( | ||||||
|                                                         importInProgress = true; |  | ||||||
|                                                       }); |  | ||||||
|                                                       addApps(urls) |  | ||||||
|                                                           .then((errors) { |  | ||||||
|                                                         if (errors.isEmpty) { |  | ||||||
|                                                           ScaffoldMessenger.of( |  | ||||||
|                                                                   context) |  | ||||||
|                                                               .showSnackBar( |  | ||||||
|                                                             SnackBar( |  | ||||||
|                                                                 content: Text( |  | ||||||
|                                                                     'Imported ${urls.length} Apps')), |  | ||||||
|                                                           ); |  | ||||||
|                                                         } else { |  | ||||||
|                                                           showDialog( |  | ||||||
|                                                               context: context, |                                                               context: context, | ||||||
|                                                               builder: |                                                               builder: | ||||||
|                                                                   (BuildContext |                                                                   (BuildContext | ||||||
|                                                                       ctx) { |                                                                       ctx) { | ||||||
|                                                                 return ImportErrorDialog( |                                                                 return UrlSelectionModal( | ||||||
|                                                                     urlsLength: urls |                                                                     urls: urls); | ||||||
|                                                                         .length, |                                                               }) | ||||||
|                                                                     errors: |                                                           .then((selectedUrls) { | ||||||
|                                                                         errors); |                                                         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) { |                                                     }).catchError((e) { | ||||||
|  |                                                       setState(() { | ||||||
|  |                                                         importInProgress = | ||||||
|  |                                                             false; | ||||||
|  |                                                       }); | ||||||
|                                                       ScaffoldMessenger.of( |                                                       ScaffoldMessenger.of( | ||||||
|                                                               context) |                                                               context) | ||||||
|                                                           .showSnackBar( |                                                           .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:flutter/material.dart'; | ||||||
| import 'package:obtainium/components/custom_app_bar.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/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| @@ -15,6 +17,7 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } |     } | ||||||
| @@ -22,8 +25,7 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|         backgroundColor: Theme.of(context).colorScheme.surface, |         backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|         body: CustomScrollView(slivers: <Widget>[ |         body: CustomScrollView(slivers: <Widget>[ | ||||||
|           const CustomAppBar(title: 'Settings'), |           const CustomAppBar(title: 'Settings'), | ||||||
|           SliverFillRemaining( |           SliverToBoxAdapter( | ||||||
|               hasScrollBody: true, |  | ||||||
|               child: Padding( |               child: Padding( | ||||||
|                   padding: const EdgeInsets.all(16), |                   padding: const EdgeInsets.all(16), | ||||||
|                   child: settingsProvider.prefs == null |                   child: settingsProvider.prefs == null | ||||||
| @@ -160,7 +162,7 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                               height: 16, |                               height: 16, | ||||||
|                             ), |                             ), | ||||||
|                             Text( |                             Text( | ||||||
|                               'More', |                               'Updates', | ||||||
|                               style: TextStyle( |                               style: TextStyle( | ||||||
|                                   color: Theme.of(context).colorScheme.primary), |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|                             ), |                             ), | ||||||
| @@ -169,68 +171,108 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                                     labelText: |                                     labelText: | ||||||
|                                         'Background Update Checking Interval'), |                                         'Background Update Checking Interval'), | ||||||
|                                 value: settingsProvider.updateInterval, |                                 value: settingsProvider.updateInterval, | ||||||
|                                 items: const [ |                                 items: updateIntervals.map((e) { | ||||||
|                                   DropdownMenuItem( |                                   int displayNum = (e < 60 | ||||||
|                                     value: 15, |                                           ? e | ||||||
|                                     child: Text('15 Minutes'), |                                           : e < 1440 | ||||||
|                                   ), |                                               ? e / 60 | ||||||
|                                   DropdownMenuItem( |                                               : e / 1440) | ||||||
|                                     value: 30, |                                       .round(); | ||||||
|                                     child: Text('30 Minutes'), |                                   var displayUnit = (e < 60 | ||||||
|                                   ), |                                       ? 'Minute' | ||||||
|                                   DropdownMenuItem( |                                       : e < 1440 | ||||||
|                                     value: 60, |                                           ? 'Hour' | ||||||
|                                     child: Text('1 Hour'), |                                           : 'Day'); | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |                                   String display = e == 0 | ||||||
|                                     value: 360, |                                       ? 'Never - Manual Only' | ||||||
|                                     child: Text('6 Hours'), |                                       : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; | ||||||
|                                   ), |                                   return DropdownMenuItem( | ||||||
|                                   DropdownMenuItem( |                                       value: e, child: Text(display)); | ||||||
|                                     value: 720, |                                 }).toList(), | ||||||
|                                     child: Text('12 Hours'), |  | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: 1440, |  | ||||||
|                                     child: Text('1 Day'), |  | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: 0, |  | ||||||
|                                     child: Text('Never - Manual Only'), |  | ||||||
|                                   ), |  | ||||||
|                                 ], |  | ||||||
|                                 onChanged: (value) { |                                 onChanged: (value) { | ||||||
|                                   if (value != null) { |                                   if (value != null) { | ||||||
|                                     settingsProvider.updateInterval = value; |                                     settingsProvider.updateInterval = value; | ||||||
|                                   } |                                   } | ||||||
|                                 }), |                                 }), | ||||||
|                             const Spacer(), |                             const SizedBox( | ||||||
|                             Row( |                               height: 8, | ||||||
|                               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, |  | ||||||
|                                   ), |  | ||||||
|                                 ) |  | ||||||
|                               ], |  | ||||||
|                             ), |                             ), | ||||||
|  |                             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, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ) | ||||||
|         ])); |         ])); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,53 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:obtainium/components/generated_form.dart'; |  | ||||||
|  |  | ||||||
| class TestPage extends StatefulWidget { |  | ||||||
|   const TestPage({super.key}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   State<TestPage> createState() => _TestPageState(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _TestPageState extends State<TestPage> { |  | ||||||
|   List<String?>? sourceSpecificData; |  | ||||||
|   bool valid = false; |  | ||||||
|  |  | ||||||
|   List<List<GeneratedFormItem>> sourceSpecificInputs = [ |  | ||||||
|     [GeneratedFormItem(label: 'Test Item 1')], |  | ||||||
|     [ |  | ||||||
|       GeneratedFormItem(label: 'Test Item 2', required: false), |  | ||||||
|       GeneratedFormItem(label: 'Test Item 3') |  | ||||||
|     ], |  | ||||||
|     [GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)] |  | ||||||
|   ]; |  | ||||||
|  |  | ||||||
|   List<String> defaultInputValues = ["ABC"]; |  | ||||||
|  |  | ||||||
|   void onSourceSpecificDataChanges( |  | ||||||
|       List<String?> valuesFromForm, bool formValid) { |  | ||||||
|     setState(() { |  | ||||||
|       sourceSpecificData = valuesFromForm; |  | ||||||
|       valid = formValid; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return Scaffold( |  | ||||||
|         appBar: AppBar(title: const Text('Test Page')), |  | ||||||
|         backgroundColor: Theme.of(context).colorScheme.surface, |  | ||||||
|         body: Padding( |  | ||||||
|             padding: const EdgeInsets.symmetric(horizontal: 16), |  | ||||||
|             child: Column(children: [ |  | ||||||
|               GeneratedForm( |  | ||||||
|                 items: sourceSpecificInputs, |  | ||||||
|                 onValueChanges: onSourceSpecificDataChanges, |  | ||||||
|                 defaultValues: defaultInputValues, |  | ||||||
|               ), |  | ||||||
|               ...(sourceSpecificData != null |  | ||||||
|                   ? (sourceSpecificData as List<String?>) |  | ||||||
|                       .map((e) => Text(e ?? "")) |  | ||||||
|                   : [Container()]) |  | ||||||
|             ]))); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -5,15 +5,18 @@ import 'dart:async'; | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.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/custom_errors.dart'; | ||||||
| import 'package:obtainium/providers/notifications_provider.dart'; | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:path_provider/path_provider.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
| import 'package:flutter_fgbg/flutter_fgbg.dart'; | import 'package:flutter_fgbg/flutter_fgbg.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:http/http.dart'; | import 'package:http/http.dart'; | ||||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; |  | ||||||
|  |  | ||||||
| class AppInMemory { | class AppInMemory { | ||||||
|   late App app; |   late App app; | ||||||
| @@ -62,6 +65,9 @@ class AppsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<ApkFile> downloadApp(String apkUrl, String appId) async { |   Future<ApkFile> downloadApp(String apkUrl, String appId) async { | ||||||
|  |     apkUrl = await SourceProvider() | ||||||
|  |         .getSource(apps[appId]!.app.url) | ||||||
|  |         .apkUrlPrefetchModifier(apkUrl); | ||||||
|     StreamedResponse response = |     StreamedResponse response = | ||||||
|         await Client().send(Request('GET', Uri.parse(apkUrl))); |         await Client().send(Request('GET', Uri.parse(apkUrl))); | ||||||
|     File downloadFile = |     File downloadFile = | ||||||
| @@ -96,30 +102,65 @@ class AppsProvider with ChangeNotifier { | |||||||
|       .where((element) => element.downloadProgress != null) |       .where((element) => element.downloadProgress != null) | ||||||
|       .isNotEmpty; |       .isNotEmpty; | ||||||
|  |  | ||||||
|   // Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it |   Future<bool> canInstallSilently(App app) async { | ||||||
|   // Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed |     // TODO: This is unreliable - try to get from OS in the future | ||||||
|   // Returns upon successful download, regardless of installation result |     var osInfo = await DeviceInfoPlugin().androidInfo; | ||||||
|   Future<bool> downloadAndInstallLatestApp( |     return app.installedVersion != null && | ||||||
|       List<String> appIds, BuildContext context) async { |         osInfo.version.sdkInt! >= 30 && | ||||||
|  |         osInfo.version.release!.compareTo('12') >= 0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> askUserToReturnToForeground(BuildContext context, | ||||||
|  |       {bool waitForFG = false}) async { | ||||||
|     NotificationsProvider notificationsProvider = |     NotificationsProvider notificationsProvider = | ||||||
|         context.read<NotificationsProvider>(); |         context.read<NotificationsProvider>(); | ||||||
|  |     if (!isForeground) { | ||||||
|  |       await notificationsProvider.notify(completeInstallationNotification, | ||||||
|  |           cancelExisting: true); | ||||||
|  |       if (waitForFG) { | ||||||
|  |         await FGBGEvents.stream.first == FGBGType.foreground; | ||||||
|  |         await notificationsProvider.cancel(completeInstallationNotification.id); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Unfortunately this 'await' does not actually wait for the APK to finish installing | ||||||
|  |   // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing | ||||||
|  |   // If appropriate criteria are met, the update (never a fresh install) happens silently  in the background | ||||||
|  |   // But even then, we don't know if it actually succeeded | ||||||
|  |   Future<void> installApk(ApkFile file) async { | ||||||
|  |     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]); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Given a list of AppIds, uses stored info about the apps to download APKs and install them | ||||||
|  |   // If the APKs can be installed silently, they are | ||||||
|  |   // If no BuildContext is provided, apps that require user interaction are ignored | ||||||
|  |   // If user input is needed and the App is in the background, a notification is sent to get the user's attention | ||||||
|  |   // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result | ||||||
|  |   Future<List<String>> downloadAndInstallLatestApp( | ||||||
|  |       List<String> appIds, BuildContext? context) async { | ||||||
|     Map<String, String> appsToInstall = {}; |     Map<String, String> appsToInstall = {}; | ||||||
|     for (var id in appIds) { |     for (var id in appIds) { | ||||||
|       if (apps[id] == null) { |       if (apps[id] == null) { | ||||||
|         throw 'App not found'; |         throw 'App not found'; | ||||||
|       } |       } | ||||||
|       // If the App has more than one APK, the user should pick one |  | ||||||
|  |       // If the App has more than one APK, the user should pick one (if context provided) | ||||||
|       String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; |       String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; | ||||||
|       if (apps[id]!.app.apkUrls.length > 1) { |       if (apps[id]!.app.apkUrls.length > 1 && context != null) { | ||||||
|         apkUrl = await showDialog( |         apkUrl = await showDialog( | ||||||
|             context: context, |             context: context, | ||||||
|             builder: (BuildContext ctx) { |             builder: (BuildContext ctx) { | ||||||
|               return APKPicker(app: apps[id]!.app, initVal: apkUrl); |               return APKPicker(app: apps[id]!.app, initVal: apkUrl); | ||||||
|             }); |             }); | ||||||
|       } |       } | ||||||
|       // If the picked APK comes from an origin different from the source, get user confirmation |       // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) | ||||||
|       if (apkUrl != null && |       if (apkUrl != null && | ||||||
|           Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) { |           Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin && | ||||||
|  |           context != null) { | ||||||
|         if (await showDialog( |         if (await showDialog( | ||||||
|                 context: context, |                 context: context, | ||||||
|                 builder: (BuildContext ctx) { |                 builder: (BuildContext ctx) { | ||||||
| @@ -134,35 +175,69 @@ class AppsProvider with ChangeNotifier { | |||||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); |         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { |         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||||
|           apps[id]!.app.preferredApkIndex = urlInd; |           apps[id]!.app.preferredApkIndex = urlInd; | ||||||
|           await saveApp(apps[id]!.app); |           await saveApps([apps[id]!.app]); | ||||||
|  |         } | ||||||
|  |         if (context != null || | ||||||
|  |             (await canInstallSilently(apps[id]!.app) && | ||||||
|  |                 apps[id]!.app.apkUrls.length == 1)) { | ||||||
|  |           appsToInstall.putIfAbsent(id, () => apkUrl!); | ||||||
|         } |         } | ||||||
|         appsToInstall.putIfAbsent(id, () => apkUrl!); |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries |     List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries | ||||||
|         .map((entry) => downloadApp(entry.value, entry.key))); |         .map((entry) => downloadApp(entry.value, entry.key))); | ||||||
|  |  | ||||||
|     if (!isForeground) { |     List<ApkFile> silentUpdates = []; | ||||||
|       await notificationsProvider.notify(completeInstallationNotification, |     List<ApkFile> regularInstalls = []; | ||||||
|           cancelExisting: true); |  | ||||||
|       await FGBGEvents.stream.first == FGBGType.foreground; |  | ||||||
|       await notificationsProvider.cancel(completeInstallationNotification.id); |  | ||||||
|       // We need to wait for the App to come to the foreground to install it |  | ||||||
|       // Can't try to call install plugin in a background isolate (may not have worked anyways) because of: |  | ||||||
|       // https://github.com/flutter/flutter/issues/13937 |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Unfortunately this 'await' does not actually wait for the APK to finish installing |  | ||||||
|     // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing |  | ||||||
|     // This also does not use the 'session-based' installer API, so background/silent updates are impossible |  | ||||||
|     for (var f in downloadedFiles) { |     for (var f in downloadedFiles) { | ||||||
|       await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium'); |       bool willBeSilent = await canInstallSilently(apps[f.appId]!.app); | ||||||
|       apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; |       if (willBeSilent) { | ||||||
|       await saveApp(apps[f.appId]!.app); |         silentUpdates.add(f); | ||||||
|  |       } else { | ||||||
|  |         regularInstalls.add(f); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return downloadedFiles.isNotEmpty; |     // 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, waitForFG: true); | ||||||
|  |       } | ||||||
|  |       for (var i in regularInstalls) { | ||||||
|  |         await installApk(i); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return downloadedFiles.map((e) => e.appId).toList(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<Directory> getAppsDir() async { |   Future<Directory> getAppsDir() async { | ||||||
| @@ -200,23 +275,30 @@ class AppsProvider with ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> saveApp(App app) async { |   Future<void> saveApps(List<App> apps) async { | ||||||
|     File('${(await getAppsDir()).path}/${app.id}.json') |     for (var app in apps) { | ||||||
|         .writeAsStringSync(jsonEncode(app.toJson())); |       File('${(await getAppsDir()).path}/${app.id}.json') | ||||||
|     apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), |           .writeAsStringSync(jsonEncode(app.toJson())); | ||||||
|         ifAbsent: () => AppInMemory(app, null)); |       this.apps.update( | ||||||
|  |           app.id, (value) => AppInMemory(app, value.downloadProgress), | ||||||
|  |           ifAbsent: () => AppInMemory(app, null)); | ||||||
|  |     } | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> removeApp(String appId) async { |   Future<void> removeApps(List<String> appIds) async { | ||||||
|     File file = File('${(await getAppsDir()).path}/$appId.json'); |     for (var appId in appIds) { | ||||||
|     if (file.existsSync()) { |       File file = File('${(await getAppsDir()).path}/$appId.json'); | ||||||
|       file.deleteSync(); |       if (file.existsSync()) { | ||||||
|  |         file.deleteSync(); | ||||||
|  |       } | ||||||
|  |       if (apps.containsKey(appId)) { | ||||||
|  |         apps.remove(appId); | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|     if (apps.containsKey(appId)) { |     if (appIds.isNotEmpty) { | ||||||
|       apps.remove(appId); |       notifyListeners(); | ||||||
|     } |     } | ||||||
|     notifyListeners(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool checkAppObjectForUpdate(App app) { |   bool checkAppObjectForUpdate(App app) { | ||||||
| @@ -232,42 +314,81 @@ class AppsProvider with ChangeNotifier { | |||||||
|     App newApp = await sourceProvider.getApp( |     App newApp = await sourceProvider.getApp( | ||||||
|         sourceProvider.getSource(currentApp.url), |         sourceProvider.getSource(currentApp.url), | ||||||
|         currentApp.url, |         currentApp.url, | ||||||
|         currentApp.additionalData); |         currentApp.additionalData, | ||||||
|     if (newApp.latestVersion != currentApp.latestVersion) { |         customName: currentApp.name); | ||||||
|       newApp.installedVersion = currentApp.installedVersion; |     newApp.installedVersion = currentApp.installedVersion; | ||||||
|       if (currentApp.preferredApkIndex < newApp.apkUrls.length) { |     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||||
|         newApp.preferredApkIndex = currentApp.preferredApkIndex; |       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||||
|       } |  | ||||||
|       await saveApp(newApp); |  | ||||||
|       return newApp; |  | ||||||
|     } |     } | ||||||
|     return null; |     await saveApps([newApp]); | ||||||
|  |     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<App>> checkUpdates() async { |   Future<List<App>> checkUpdates( | ||||||
|  |       {DateTime? ignoreAfter, | ||||||
|  |       bool immediatelyThrowRateLimitError = false}) async { | ||||||
|     List<App> updates = []; |     List<App> updates = []; | ||||||
|  |     Map<String, List<String>> errors = {}; | ||||||
|     if (!gettingUpdates) { |     if (!gettingUpdates) { | ||||||
|       gettingUpdates = true; |       gettingUpdates = true; | ||||||
|  |  | ||||||
|       List<String> appIds = apps.keys.toList(); |       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++) { |       for (int i = 0; i < appIds.length; i++) { | ||||||
|         App? newApp = await getUpdate(appIds[i]); |         App? newApp; | ||||||
|  |         try { | ||||||
|  |           newApp = await getUpdate(appIds[i]); | ||||||
|  |         } catch (e) { | ||||||
|  |           if (e is RateLimitError && immediatelyThrowRateLimitError) { | ||||||
|  |             rethrow; | ||||||
|  |           } | ||||||
|  |           var tempIds = errors.remove(e.toString()); | ||||||
|  |           tempIds ??= []; | ||||||
|  |           tempIds.add(appIds[i]); | ||||||
|  |           errors.putIfAbsent(e.toString(), () => tempIds!); | ||||||
|  |         } | ||||||
|         if (newApp != null) { |         if (newApp != null) { | ||||||
|           updates.add(newApp); |           updates.add(newApp); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       gettingUpdates = false; |       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; |     return updates; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   List<String> getExistingUpdates() { |   List<String> getExistingUpdates( | ||||||
|  |       {bool installedOnly = false, bool nonInstalledOnly = false}) { | ||||||
|     List<String> updateAppIds = []; |     List<String> updateAppIds = []; | ||||||
|     List<String> appIds = apps.keys.toList(); |     List<String> appIds = apps.keys.toList(); | ||||||
|     for (int i = 0; i < appIds.length; i++) { |     for (int i = 0; i < appIds.length; i++) { | ||||||
|       App? app = apps[appIds[i]]!.app; |       App? app = apps[appIds[i]]!.app; | ||||||
|       if (app.installedVersion != app.latestVersion) { |       if (app.installedVersion != app.latestVersion && | ||||||
|         updateAppIds.add(app.id); |           (!installedOnly || !nonInstalledOnly)) { | ||||||
|  |         if ((app.installedVersion == null && | ||||||
|  |                 (nonInstalledOnly || !installedOnly) || | ||||||
|  |             (app.installedVersion != null && | ||||||
|  |                 (installedOnly || !nonInstalledOnly)))) { | ||||||
|  |           updateAppIds.add(app.id); | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return updateAppIds; |     return updateAppIds; | ||||||
| @@ -295,8 +416,8 @@ class AppsProvider with ChangeNotifier { | |||||||
|     for (App a in importedApps) { |     for (App a in importedApps) { | ||||||
|       a.installedVersion = |       a.installedVersion = | ||||||
|           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; |           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; | ||||||
|       await saveApp(a); |  | ||||||
|     } |     } | ||||||
|  |     await saveApps(importedApps); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|     return importedApps.length; |     return importedApps.length; | ||||||
|   } |   } | ||||||
| @@ -331,7 +452,10 @@ class _APKPickerState extends State<APKPicker> { | |||||||
|         Text('${widget.app.name} has more than one package:'), |         Text('${widget.app.name} has more than one package:'), | ||||||
|         const SizedBox(height: 16), |         const SizedBox(height: 16), | ||||||
|         ...widget.app.apkUrls.map((u) => RadioListTile<String>( |         ...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, |             value: u, | ||||||
|             groupValue: apkUrl, |             groupValue: apkUrl, | ||||||
|             onChanged: (String? val) { |             onChanged: (String? val) { | ||||||
|   | |||||||
| @@ -33,6 +33,22 @@ class UpdateNotification extends ObtainiumNotification { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class SilentUpdateNotification extends ObtainiumNotification { | ||||||
|  |   SilentUpdateNotification(List<App> updates) | ||||||
|  |       : super( | ||||||
|  |             3, | ||||||
|  |             'Apps Updated', | ||||||
|  |             '', | ||||||
|  |             'APPS_UPDATED', | ||||||
|  |             'Apps Updated', | ||||||
|  |             'Notifies the user that updates to one or more Apps were applied in the background', | ||||||
|  |             Importance.defaultImportance) { | ||||||
|  |     message = updates.length == 1 | ||||||
|  |         ? '${updates[0].name} was updated to ${updates[0].latestVersion}.' | ||||||
|  |         : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.'; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class ErrorCheckingUpdatesNotification extends ObtainiumNotification { | class ErrorCheckingUpdatesNotification extends ObtainiumNotification { | ||||||
|   ErrorCheckingUpdatesNotification(String error) |   ErrorCheckingUpdatesNotification(String error) | ||||||
|       : super( |       : super( | ||||||
|   | |||||||
| @@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName } | |||||||
|  |  | ||||||
| enum SortOrderSettings { ascending, descending } | 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 { | class SettingsProvider with ChangeNotifier { | ||||||
|   SharedPreferences? prefs; |   SharedPreferences? prefs; | ||||||
|  |  | ||||||
| @@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   int get updateInterval { |   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) { |   set updateInterval(int min) { | ||||||
| @@ -54,8 +74,8 @@ class SettingsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   SortColumnSettings get sortColumn { |   SortColumnSettings get sortColumn { | ||||||
|     return SortColumnSettings |     return SortColumnSettings.values[ | ||||||
|         .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; |         prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   set sortColumn(SortColumnSettings s) { |   set sortColumn(SortColumnSettings s) { | ||||||
| @@ -65,7 +85,7 @@ class SettingsProvider with ChangeNotifier { | |||||||
|  |  | ||||||
|   SortOrderSettings get sortOrder { |   SortOrderSettings get sortOrder { | ||||||
|     return SortOrderSettings.values[ |     return SortOrderSettings.values[ | ||||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; |         prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   set sortOrder(SortOrderSettings s) { |   set sortOrder(SortOrderSettings s) { | ||||||
| @@ -95,11 +115,20 @@ class SettingsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool get showAppWebpage { |   bool get showAppWebpage { | ||||||
|     return prefs?.getBool('showAppWebpage') ?? true; |     return prefs?.getBool('showAppWebpage') ?? false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   set showAppWebpage(bool show) { |   set showAppWebpage(bool show) { | ||||||
|     prefs?.setBool('showAppWebpage', show); |     prefs?.setBool('showAppWebpage', show); | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   String? getSettingString(String settingId) { | ||||||
|  |     return prefs?.getString(settingId); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setSettingString(String settingId, String value) { | ||||||
|  |     prefs?.setString(settingId, value); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,12 +4,14 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:html/dom.dart'; | import 'package:html/dom.dart'; | ||||||
|  | import 'package:obtainium/app_sources/apkmirror.dart'; | ||||||
| import 'package:obtainium/app_sources/fdroid.dart'; | import 'package:obtainium/app_sources/fdroid.dart'; | ||||||
| import 'package:obtainium/app_sources/github.dart'; | import 'package:obtainium/app_sources/github.dart'; | ||||||
| import 'package:obtainium/app_sources/gitlab.dart'; | import 'package:obtainium/app_sources/gitlab.dart'; | ||||||
| import 'package:obtainium/app_sources/izzyondroid.dart'; | import 'package:obtainium/app_sources/izzyondroid.dart'; | ||||||
| import 'package:obtainium/app_sources/mullvad.dart'; | import 'package:obtainium/app_sources/mullvad.dart'; | ||||||
| import 'package:obtainium/app_sources/signal.dart'; | import 'package:obtainium/app_sources/signal.dart'; | ||||||
|  | import 'package:obtainium/app_sources/sourceforge.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
| import 'package:obtainium/mass_app_sources/githubstars.dart'; | import 'package:obtainium/mass_app_sources/githubstars.dart'; | ||||||
|  |  | ||||||
| @@ -37,6 +39,7 @@ class App { | |||||||
|   List<String> apkUrls = []; |   List<String> apkUrls = []; | ||||||
|   late int preferredApkIndex; |   late int preferredApkIndex; | ||||||
|   late List<String> additionalData; |   late List<String> additionalData; | ||||||
|  |   late DateTime? lastUpdateCheck; | ||||||
|   App( |   App( | ||||||
|       this.id, |       this.id, | ||||||
|       this.url, |       this.url, | ||||||
| @@ -46,7 +49,8 @@ class App { | |||||||
|       this.latestVersion, |       this.latestVersion, | ||||||
|       this.apkUrls, |       this.apkUrls, | ||||||
|       this.preferredApkIndex, |       this.preferredApkIndex, | ||||||
|       this.additionalData); |       this.additionalData, | ||||||
|  |       this.lastUpdateCheck); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String toString() { |   String toString() { | ||||||
| @@ -68,7 +72,10 @@ class App { | |||||||
|       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, |       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, | ||||||
|       json['additionalData'] == null |       json['additionalData'] == null | ||||||
|           ? SourceProvider().getSource(json['url']).additionalDataDefaults |           ? 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() => { |   Map<String, dynamic> toJson() => { | ||||||
|         'id': id, |         'id': id, | ||||||
| @@ -79,17 +86,18 @@ class App { | |||||||
|         'latestVersion': latestVersion, |         'latestVersion': latestVersion, | ||||||
|         'apkUrls': jsonEncode(apkUrls), |         'apkUrls': jsonEncode(apkUrls), | ||||||
|         'preferredApkIndex': preferredApkIndex, |         'preferredApkIndex': preferredApkIndex, | ||||||
|         'additionalData': jsonEncode(additionalData) |         'additionalData': jsonEncode(additionalData), | ||||||
|  |         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch | ||||||
|       }; |       }; | ||||||
| } | } | ||||||
|  |  | ||||||
| escapeRegEx(String s) { | escapeRegEx(String s) { | ||||||
|   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { |   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||||
|     return "\\${x[0]}"; |     return '\\${x[0]}'; | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| makeUrlHttps(String url) { | preStandardizeUrl(String url) { | ||||||
|   if (url.toLowerCase().indexOf('http://') != 0 && |   if (url.toLowerCase().indexOf('http://') != 0 && | ||||||
|       url.toLowerCase().indexOf('https://') != 0) { |       url.toLowerCase().indexOf('https://') != 0) { | ||||||
|     url = 'https://$url'; |     url = 'https://$url'; | ||||||
| @@ -128,6 +136,9 @@ abstract class AppSource { | |||||||
|   AppNames getAppNames(String standardUrl); |   AppNames getAppNames(String standardUrl); | ||||||
|   late List<List<GeneratedFormItem>> additionalDataFormItems; |   late List<List<GeneratedFormItem>> additionalDataFormItems; | ||||||
|   late List<String> additionalDataDefaults; |   late List<String> additionalDataDefaults; | ||||||
|  |   late List<GeneratedFormItem> moreSourceSettingsFormItems; | ||||||
|  |   String? changeLogPageFromStandardUrl(String standardUrl); | ||||||
|  |   Future<String> apkUrlPrefetchModifier(String apkUrl); | ||||||
| } | } | ||||||
|  |  | ||||||
| abstract class MassAppSource { | abstract class MassAppSource { | ||||||
| @@ -144,14 +155,16 @@ class SourceProvider { | |||||||
|     FDroid(), |     FDroid(), | ||||||
|     IzzyOnDroid(), |     IzzyOnDroid(), | ||||||
|     Mullvad(), |     Mullvad(), | ||||||
|     Signal() |     Signal(), | ||||||
|  |     SourceForge(), | ||||||
|  |     APKMirror() | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   // Add more mass source classes here so they are available via the service |   // Add more mass source classes here so they are available via the service | ||||||
|   List<MassAppSource> massSources = [GitHubStars()]; |   List<MassAppSource> massSources = [GitHubStars()]; | ||||||
|  |  | ||||||
|   AppSource getSource(String url) { |   AppSource getSource(String url) { | ||||||
|     url = makeUrlHttps(url); |     url = preStandardizeUrl(url); | ||||||
|     AppSource? source; |     AppSource? source; | ||||||
|     for (var s in sources) { |     for (var s in sources) { | ||||||
|       if (url.toLowerCase().contains('://${s.host}')) { |       if (url.toLowerCase().contains('://${s.host}')) { | ||||||
| @@ -176,9 +189,9 @@ class SourceProvider { | |||||||
|     return false; |     return false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<App> getApp( |   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||||
|       AppSource source, String url, List<String> additionalData) async { |       {String customName = ''}) async { | ||||||
|     String standardUrl = source.standardizeURL(makeUrlHttps(url)); |     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||||
|     AppNames names = source.getAppNames(standardUrl); |     AppNames names = source.getAppNames(standardUrl); | ||||||
|     APKDetails apk = |     APKDetails apk = | ||||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); |         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||||
| @@ -186,20 +199,24 @@ class SourceProvider { | |||||||
|         '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', |         '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', | ||||||
|         standardUrl, |         standardUrl, | ||||||
|         names.author[0].toUpperCase() + names.author.substring(1), |         names.author[0].toUpperCase() + names.author.substring(1), | ||||||
|         names.name[0].toUpperCase() + names.name.substring(1), |         customName.trim().isNotEmpty | ||||||
|  |             ? customName | ||||||
|  |             : names.name[0].toUpperCase() + names.name.substring(1), | ||||||
|         null, |         null, | ||||||
|         apk.version, |         apk.version, | ||||||
|         apk.apkUrls, |         apk.apkUrls, | ||||||
|         apk.apkUrls.length - 1, |         apk.apkUrls.length - 1, | ||||||
|         additionalData); |         additionalData, | ||||||
|  |         DateTime.now()); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /// Returns a length 2 list, where the first element is a list of Apps and |   /// 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 |   /// 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 = []; |     List<App> apps = []; | ||||||
|     Map<String, dynamic> errors = {}; |     Map<String, dynamic> errors = {}; | ||||||
|     for (var url in urls) { |     for (var url in urls.where((element) => !ignoreUrls.contains(element))) { | ||||||
|       try { |       try { | ||||||
|         var source = getSource(url); |         var source = getSource(url); | ||||||
|         apps.add(await getApp(source, url, source.additionalDataDefaults)); |         apps.add(await getApp(source, url, source.additionalDataDefaults)); | ||||||
|   | |||||||
							
								
								
									
										104
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										104
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -7,7 +7,7 @@ packages: | |||||||
|       name: animations |       name: animations | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.4" |     version: "2.0.7" | ||||||
|   archive: |   archive: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -71,6 +71,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.16.0" |     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: |   crypto: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -105,42 +112,42 @@ packages: | |||||||
|       name: device_info_plus |       name: device_info_plus | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.1.2" |     version: "5.0.5" | ||||||
|   device_info_plus_linux: |   device_info_plus_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: device_info_plus_linux |       name: device_info_plus_linux | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.0" |     version: "4.0.2" | ||||||
|   device_info_plus_macos: |   device_info_plus_macos: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: device_info_plus_macos |       name: device_info_plus_macos | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.0" |     version: "4.0.2" | ||||||
|   device_info_plus_platform_interface: |   device_info_plus_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: device_info_plus_platform_interface |       name: device_info_plus_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.0" |     version: "4.0.1" | ||||||
|   device_info_plus_web: |   device_info_plus_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: device_info_plus_web |       name: device_info_plus_web | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.0" |     version: "4.0.2" | ||||||
|   device_info_plus_windows: |   device_info_plus_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: device_info_plus_windows |       name: device_info_plus_windows | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.1.0" |     version: "5.0.2" | ||||||
|   dynamic_color: |   dynamic_color: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -175,7 +182,7 @@ packages: | |||||||
|       name: file_picker |       name: file_picker | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.1.0" |     version: "5.2.0+1" | ||||||
|   flutter: |   flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -208,7 +215,7 @@ packages: | |||||||
|       name: flutter_local_notifications |       name: flutter_local_notifications | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "10.0.0" |     version: "12.0.0" | ||||||
|   flutter_local_notifications_linux: |   flutter_local_notifications_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -295,7 +302,7 @@ packages: | |||||||
|       name: json_annotation |       name: json_annotation | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "4.6.0" |     version: "4.7.0" | ||||||
|   lints: |   lints: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -324,6 +331,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.8.0" |     version: "1.8.0" | ||||||
|  |   mime: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: mime | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.0.2" | ||||||
|   nested: |   nested: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -379,7 +393,7 @@ packages: | |||||||
|       name: path_provider_platform_interface |       name: path_provider_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.4" |     version: "2.0.5" | ||||||
|   path_provider_windows: |   path_provider_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -393,35 +407,35 @@ packages: | |||||||
|       name: permission_handler |       name: permission_handler | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "10.0.0" |     version: "10.1.0" | ||||||
|   permission_handler_android: |   permission_handler_android: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: permission_handler_android |       name: permission_handler_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "10.0.0" |     version: "10.1.0" | ||||||
|   permission_handler_apple: |   permission_handler_apple: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: permission_handler_apple |       name: permission_handler_apple | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "9.0.4" |     version: "9.0.6" | ||||||
|   permission_handler_platform_interface: |   permission_handler_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: permission_handler_platform_interface |       name: permission_handler_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.7.0" |     version: "3.9.0" | ||||||
|   permission_handler_windows: |   permission_handler_windows: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: permission_handler_windows |       name: permission_handler_windows | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.1.0" |     version: "0.1.1" | ||||||
|   petitparser: |   petitparser: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -457,6 +471,48 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.3" |     version: "6.0.3" | ||||||
|  |   share_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: share_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "4.5.3" | ||||||
|  |   share_plus_linux: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_linux | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   share_plus_macos: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_macos | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.1" | ||||||
|  |   share_plus_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.1.1" | ||||||
|  |   share_plus_web: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_web | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.1.0" | ||||||
|  |   share_plus_windows: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: share_plus_windows | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.1" | ||||||
|   shared_preferences: |   shared_preferences: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -538,7 +594,7 @@ packages: | |||||||
|       name: stream_channel |       name: stream_channel | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.0" |     version: "2.1.1" | ||||||
|   string_scanner: |   string_scanner: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -566,7 +622,7 @@ packages: | |||||||
|       name: timezone |       name: timezone | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.8.0" |     version: "0.9.0" | ||||||
|   typed_data: |   typed_data: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -580,7 +636,7 @@ packages: | |||||||
|       name: url_launcher |       name: url_launcher | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.5" |     version: "6.1.6" | ||||||
|   url_launcher_android: |   url_launcher_android: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -615,7 +671,7 @@ packages: | |||||||
|       name: url_launcher_platform_interface |       name: url_launcher_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.0" |     version: "2.1.1" | ||||||
|   url_launcher_web: |   url_launcher_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -636,7 +692,7 @@ packages: | |||||||
|       name: vector_math |       name: vector_math | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.3" |     version: "2.1.4" | ||||||
|   webview_flutter: |   webview_flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -650,21 +706,21 @@ packages: | |||||||
|       name: webview_flutter_android |       name: webview_flutter_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.10.1" |     version: "2.10.4" | ||||||
|   webview_flutter_platform_interface: |   webview_flutter_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_platform_interface |       name: webview_flutter_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.3" |     version: "1.9.5" | ||||||
|   webview_flutter_wkwebview: |   webview_flutter_wkwebview: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_wkwebview |       name: webview_flutter_wkwebview | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.4" |     version: "2.9.5" | ||||||
|   win32: |   win32: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     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 | # 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 | # 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. | # of the product and file versions while build-number is used as the build suffix. | ||||||
| version: 0.3.2+18 # When changing this, update the tag in main() accordingly | version: 0.5.10+31 # When changing this, update the tag in main() accordingly | ||||||
|  |  | ||||||
| environment: | environment: | ||||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' |   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||||
| @@ -38,21 +38,22 @@ dependencies: | |||||||
|   cupertino_icons: ^1.0.5 |   cupertino_icons: ^1.0.5 | ||||||
|   path_provider: ^2.0.11 |   path_provider: ^2.0.11 | ||||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this |   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||||
|   flutter_local_notifications: ^10.0.0 |   flutter_local_notifications: ^12.0.0 | ||||||
|   provider: ^6.0.3 |   provider: ^6.0.3 | ||||||
|   http: ^0.13.5 |   http: ^0.13.5 | ||||||
|   webview_flutter: ^3.0.4 |   webview_flutter: ^3.0.4 | ||||||
|   workmanager: ^0.5.0 |   workmanager: ^0.5.0 | ||||||
|   dynamic_color: ^1.5.4 |   dynamic_color: ^1.5.4 | ||||||
|   install_plugin_v2: ^1.0.0 # Try replacing this |  | ||||||
|   html: ^0.15.0 |   html: ^0.15.0 | ||||||
|   shared_preferences: ^2.0.15 |   shared_preferences: ^2.0.15 | ||||||
|   url_launcher: ^6.1.5 |   url_launcher: ^6.1.5 | ||||||
|   permission_handler: ^10.0.0 |   permission_handler: ^10.0.0 | ||||||
|   fluttertoast: ^8.0.9 |   fluttertoast: ^8.0.9 | ||||||
|   device_info_plus: ^4.1.2 |   device_info_plus: ^5.0.5 | ||||||
|   file_picker: ^5.1.0 |   file_picker: ^5.1.0 | ||||||
|   animations: ^2.0.4 |   animations: ^2.0.4 | ||||||
|  |   install_plugin_v2: ^1.0.0 | ||||||
|  |   share_plus: ^4.4.0 | ||||||
|  |  | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ import 'package:obtainium/main.dart'; | |||||||
| void main() { | void main() { | ||||||
|   testWidgets('Counter increments smoke test', (WidgetTester tester) async { |   testWidgets('Counter increments smoke test', (WidgetTester tester) async { | ||||||
|     // Build our app and trigger a frame. |     // Build our app and trigger a frame. | ||||||
|     await tester.pumpWidget(const MyApp()); |     await tester.pumpWidget(const Obtainium()); | ||||||
|  |  | ||||||
|     // Verify that our counter starts at 0. |     // Verify that our counter starts at 0. | ||||||
|     expect(find.text('0'), findsOneWidget); |     expect(find.text('0'), findsOneWidget); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user