Compare commits
	
		
			41 Commits
		
	
	
		
			v0.6.11-be
			...
			v0.8.1-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 086b2b949f | ||
|  | 9b5b212e96 | ||
|  | 6c8f9ebcbf | ||
|  | 4d5773bdcc | ||
|  | f81ef6a416 | ||
|  | 47324fcb49 | ||
|  | 377e0e07bd | ||
|  | b5aae70274 | ||
|  | 42475fa42a | ||
|  | d29534ef2e | ||
|  | 25953399ac | ||
|  | b04d2fad5c | ||
|  | 868ba84c9a | ||
|  | 602f0c3bb2 | ||
|  | 00721e8ac4 | ||
|  | d19f9101d6 | ||
|  | a4bc278e4c | ||
|  | b04986622b | ||
|  | 2059e4fd44 | ||
|  | 618a1523cf | ||
|  | ba1cdc2c73 | ||
|  | aa2a25fffe | ||
|  | c8ec67aef3 | ||
|  | 9576a99a4e | ||
|  | 0202224fa6 | ||
|  | 631ffd5c34 | ||
|  | feed7ffc0b | ||
|  | 296485de8a | ||
|  | d2f226d442 | ||
|  | cbdb449e35 | ||
|  | 3100a3a08c | ||
|  | 18951d6461 | ||
|  | 0e0a39a40f | ||
|  | 55cae0620b | ||
|  | ba6cea3ae6 | ||
|  | 4be33374c2 | ||
|  | e2bf834981 | ||
|  | 9bd7ddb21b | ||
|  | 905a807ee9 | ||
|  | ab57b97875 | ||
|  | 5db2c5f0b1 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -9,6 +9,7 @@ | ||||
| .history | ||||
| .svn/ | ||||
| migrate_working_dir/ | ||||
| .vscode/ | ||||
|  | ||||
| # IntelliJ related | ||||
| *.iml | ||||
|   | ||||
| Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB | 
| Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB | 
| Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB | 
| Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB | 
| Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB | 
| Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.6 KiB | 
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB | 
| Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB | 
| Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB | 
| Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB | 
| Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 109 KiB | 
| Before Width: | Height: | Size: 21 KiB | 
							
								
								
									
										58
									
								
								lib/app_sources/apkmirror.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class APKMirror extends AppSource { | ||||
|   APKMirror() { | ||||
|     host = 'apkmirror.com'; | ||||
|     enforceTrackOnly = true; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(runtimeType.toString()); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/#whatsnew'; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/feed')); | ||||
|     if (res.statusCode == 200) { | ||||
|       String? titleString = parse(res.body) | ||||
|           .querySelector('item') | ||||
|           ?.querySelector('title') | ||||
|           ?.innerHtml; | ||||
|       String? version = titleString | ||||
|           ?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0, | ||||
|               RegExp(' by ').firstMatch(titleString)?.start ?? 0) | ||||
|           .trim(); | ||||
|       if (version == null || version.isEmpty) { | ||||
|         version = titleString; | ||||
|       } | ||||
|       if (version == null || version.isEmpty) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, []); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @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]); | ||||
|   } | ||||
| } | ||||
| @@ -1,12 +1,13 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class FDroid implements AppSource { | ||||
|   @override | ||||
|   late String host = 'f-droid.org'; | ||||
| class FDroid extends AppSource { | ||||
|   FDroid() { | ||||
|     host = 'f-droid.org'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -28,45 +29,25 @@ class FDroid implements AppSource { | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|   String? tryInferringAppId(String standardUrl) { | ||||
|     return Uri.parse(standardUrl).pathSegments.last; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     Response res = await get(Uri.parse(standardUrl)); | ||||
|   APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|       Response res, String apkUrlPrefix) { | ||||
|     if (res.statusCode == 200) { | ||||
|       var releases = parse(res.body).querySelectorAll('.package-version'); | ||||
|       List<dynamic> releases = jsonDecode(res.body)['packages'] ?? []; | ||||
|       if (releases.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       String? latestVersion = releases[0] | ||||
|           .querySelector('.package-version-header b') | ||||
|           ?.innerHtml | ||||
|           .split(' ') | ||||
|           .sublist(1) | ||||
|           .join(' '); | ||||
|       String? latestVersion = releases[0]['versionName']; | ||||
|       if (latestVersion == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       List<String> apkUrls = releases | ||||
|           .where((element) => | ||||
|               element | ||||
|                   .querySelector('.package-version-header b') | ||||
|                   ?.innerHtml | ||||
|                   .split(' ') | ||||
|                   .sublist(1) | ||||
|                   .join(' ') == | ||||
|               latestVersion) | ||||
|           .map((e) => | ||||
|               e | ||||
|                   .querySelector('.package-version-download a') | ||||
|                   ?.attributes['href'] ?? | ||||
|               '') | ||||
|           .where((element) => element.isNotEmpty) | ||||
|           .where((element) => element['versionName'] == latestVersion) | ||||
|           .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||
|           .toList(); | ||||
|       if (apkUrls.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       return APKDetails(latestVersion, apkUrls); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
| @@ -74,16 +55,17 @@ class FDroid implements AppSource { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     String? appId = tryInferringAppId(standardUrl); | ||||
|     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')), | ||||
|         'https://f-droid.org/repo/$appId'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -7,9 +7,81 @@ import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class GitHub implements AppSource { | ||||
|   @override | ||||
|   late String host = 'github.com'; | ||||
| class GitHub extends AppSource { | ||||
|   GitHub() { | ||||
|     host = 'github.com'; | ||||
|  | ||||
|     additionalSourceAppSpecificDefaults = ['true', 'true', '']; | ||||
|  | ||||
|     additionalSourceSpecificSettingFormItems = [ | ||||
|       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), | ||||
|                 )) | ||||
|           ]) | ||||
|     ]; | ||||
|  | ||||
|     additionalSourceAppSpecificFormItems = [ | ||||
|       [ | ||||
|         GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool) | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormItem( | ||||
|             label: 'Fallback to older releases', type: FormItemType.bool) | ||||
|       ], | ||||
|       [ | ||||
|         GeneratedFormItem( | ||||
|             label: 'Filter Release Titles by Regular Expression', | ||||
|             type: FormItemType.string, | ||||
|             required: false, | ||||
|             additionalValidators: [ | ||||
|               (value) { | ||||
|                 if (value == null || value.isEmpty) { | ||||
|                   return null; | ||||
|                 } | ||||
|                 try { | ||||
|                   RegExp(value); | ||||
|                 } catch (e) { | ||||
|                   return 'Invalid regular expression'; | ||||
|                 } | ||||
|                 return null; | ||||
|               } | ||||
|             ]) | ||||
|       ] | ||||
|     ]; | ||||
|  | ||||
|     canSearch = true; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -24,8 +96,8 @@ class GitHub implements AppSource { | ||||
|   Future<String> getCredentialPrefixIfAny() async { | ||||
|     SettingsProvider settingsProvider = SettingsProvider(); | ||||
|     await settingsProvider.initializeSettings(); | ||||
|     String? creds = | ||||
|         settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); | ||||
|     String? creds = settingsProvider | ||||
|         .getSettingString(additionalSourceSpecificSettingFormItems[0].id); | ||||
|     return creds != null && creds.isNotEmpty ? '$creds@' : ''; | ||||
|   } | ||||
|  | ||||
| @@ -33,12 +105,10 @@ class GitHub implements AppSource { | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/releases'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     var includePrereleases = | ||||
|         additionalData.isNotEmpty && additionalData[0] == 'true'; | ||||
|     var fallbackToOlderReleases = | ||||
| @@ -76,7 +146,7 @@ class GitHub implements AppSource { | ||||
|           continue; | ||||
|         } | ||||
|         var apkUrls = getReleaseAPKUrls(releases[i]); | ||||
|         if (apkUrls.isEmpty) { | ||||
|         if (apkUrls.isEmpty && !trackOnly) { | ||||
|           continue; | ||||
|         } | ||||
|         targetRelease = releases[i]; | ||||
| @@ -86,23 +156,14 @@ class GitHub implements AppSource { | ||||
|       if (targetRelease == null) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       if ((targetRelease['apkUrls'] as List<String>).isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       String? version = targetRelease['tag_name']; | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, targetRelease['apkUrls']); | ||||
|       return APKDetails(version, targetRelease['apkUrls'] as List<String>); | ||||
|     } else { | ||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|         throw RateLimitError( | ||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                     60000000) | ||||
|                 .round()); | ||||
|       } | ||||
|  | ||||
|       throw NoReleasesError(); | ||||
|       rateLimitErrorCheck(res); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -114,72 +175,31 @@ class GitHub implements AppSource { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = [ | ||||
|     [GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: 'Fallback to older releases', type: FormItemType.bool) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: 'Filter Release Titles by Regular Expression', | ||||
|           type: FormItemType.string, | ||||
|           required: false, | ||||
|           additionalValidators: [ | ||||
|             (value) { | ||||
|               if (value == null || value.isEmpty) { | ||||
|                 return null; | ||||
|               } | ||||
|               try { | ||||
|                 RegExp(value); | ||||
|               } catch (e) { | ||||
|                 return 'Invalid regular expression'; | ||||
|               } | ||||
|               return null; | ||||
|             } | ||||
|           ]) | ||||
|     ] | ||||
|   ]; | ||||
|   Future<Map<String, String>> search(String query) async { | ||||
|     Response res = await get(Uri.parse( | ||||
|         'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100')); | ||||
|     if (res.statusCode == 200) { | ||||
|       Map<String, String> urlsWithDescriptions = {}; | ||||
|       for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) { | ||||
|         urlsWithDescriptions.addAll({ | ||||
|           e['html_url'] as String: e['description'] != null | ||||
|               ? e['description'] as String | ||||
|               : 'No description' | ||||
|         }); | ||||
|       } | ||||
|       return urlsWithDescriptions; | ||||
|     } else { | ||||
|       rateLimitErrorCheck(res); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = ['true', 'true', '']; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = [ | ||||
|     GeneratedFormItem( | ||||
|         label: 'GitHub Personal Access Token (Increases Rate Limit)', | ||||
|         id: 'github-creds', | ||||
|         required: false, | ||||
|         additionalValidators: [ | ||||
|           (value) { | ||||
|             if (value != null && value.trim().isNotEmpty) { | ||||
|               if (value | ||||
|                       .split(':') | ||||
|                       .where((element) => element.trim().isNotEmpty) | ||||
|                       .length != | ||||
|                   2) { | ||||
|                 return 'PAT must be in this format: username:token'; | ||||
|               } | ||||
|             } | ||||
|             return null; | ||||
|           } | ||||
|         ], | ||||
|         hint: 'username:token', | ||||
|         belowWidgets: [ | ||||
|           const SizedBox( | ||||
|             height: 8, | ||||
|           ), | ||||
|           GestureDetector( | ||||
|               onTap: () { | ||||
|                 launchUrlString( | ||||
|                     'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', | ||||
|                     mode: LaunchMode.externalApplication); | ||||
|               }, | ||||
|               child: const Text( | ||||
|                 'About GitHub PATs', | ||||
|                 style: TextStyle( | ||||
|                     decoration: TextDecoration.underline, fontSize: 12), | ||||
|               )) | ||||
|         ]) | ||||
|   ]; | ||||
|   rateLimitErrorCheck(Response res) { | ||||
|     if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|       throw RateLimitError( | ||||
|           (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                   60000000) | ||||
|               .round()); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class GitLab implements AppSource { | ||||
|   @override | ||||
|   late String host = 'gitlab.com'; | ||||
| class GitLab extends AppSource { | ||||
|   GitLab() { | ||||
|     host = 'gitlab.com'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -23,12 +23,10 @@ class GitLab implements AppSource { | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/-/releases'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var standardUri = Uri.parse(standardUrl); | ||||
| @@ -36,7 +34,7 @@ class GitLab implements AppSource { | ||||
|       var entry = parsedHtml.querySelector('entry'); | ||||
|       var entryContent = | ||||
|           parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); | ||||
|       var apkUrlList = [ | ||||
|       var apkUrls = [ | ||||
|         ...getLinksFromParsedHTML( | ||||
|             entryContent, | ||||
|             RegExp( | ||||
| @@ -51,9 +49,6 @@ class GitLab implements AppSource { | ||||
|             .where((element) => Uri.parse(element).host != '') | ||||
|             .toList() | ||||
|       ]; | ||||
|       if (apkUrlList.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|  | ||||
|       var entryId = entry?.querySelector('id')?.innerHtml; | ||||
|       var version = | ||||
| @@ -61,7 +56,7 @@ class GitLab implements AppSource { | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, apkUrlList); | ||||
|       return APKDetails(version, apkUrls); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
| @@ -72,13 +67,4 @@ class GitLab implements AppSource { | ||||
|     // Same as GitHub | ||||
|     return GitHub().getAppNames(standardUrl); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class IzzyOnDroid implements AppSource { | ||||
|   @override | ||||
|   late String host = 'android.izzysoft.de'; | ||||
| class IzzyOnDroid extends AppSource { | ||||
|   IzzyOnDroid() { | ||||
|     host = 'android.izzysoft.de'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -22,54 +22,23 @@ class IzzyOnDroid implements AppSource { | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|   String? tryInferringAppId(String standardUrl) { | ||||
|     return FDroid().tryInferringAppId(standardUrl); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     Response res = await get(Uri.parse(standardUrl)); | ||||
|     if (res.statusCode == 200) { | ||||
|       var parsedHtml = parse(res.body); | ||||
|       var multipleVersionApkUrls = parsedHtml | ||||
|           .querySelectorAll('a') | ||||
|           .where((element) => | ||||
|               element.attributes['href']?.toLowerCase().endsWith('.apk') ?? | ||||
|               false) | ||||
|           .map((e) => 'https://$host${e.attributes['href'] ?? ''}') | ||||
|           .toList(); | ||||
|       if (multipleVersionApkUrls.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       var version = parsedHtml | ||||
|           .querySelector('#keydata') | ||||
|           ?.querySelectorAll('b') | ||||
|           .where( | ||||
|               (element) => element.innerHtml.toLowerCase().contains('version')) | ||||
|           .toList()[0] | ||||
|           .parentNode | ||||
|           ?.parentNode | ||||
|           ?.children[1] | ||||
|           .innerHtml; | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, [multipleVersionApkUrls[0]]); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     String? appId = tryInferringAppId(standardUrl); | ||||
|     return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await get( | ||||
|             Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')), | ||||
|         'https://android.izzysoft.de/frepo/$appId'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class Mullvad implements AppSource { | ||||
|   @override | ||||
|   late String host = 'mullvad.net'; | ||||
| class Mullvad extends AppSource { | ||||
|   Mullvad() { | ||||
|     host = 'mullvad.net'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -22,12 +22,10 @@ class Mullvad implements AppSource { | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/en/download/android')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var version = parse(res.body) | ||||
| @@ -50,13 +48,4 @@ class Mullvad implements AppSource { | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import 'dart:convert'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class Signal implements AppSource { | ||||
|   @override | ||||
|   late String host = 'signal.org'; | ||||
| class Signal extends AppSource { | ||||
|   Signal() { | ||||
|     host = 'signal.org'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -16,25 +16,21 @@ class Signal implements AppSource { | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     Response res = | ||||
|         await get(Uri.parse('https://updates.$host/android/latest.json')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var json = jsonDecode(res.body); | ||||
|       String? apkUrl = json['url']; | ||||
|       if (apkUrl == null) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       List<String> apkUrls = apkUrl == null ? [] : [apkUrl]; | ||||
|       String? version = json['versionName']; | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       return APKDetails(version, [apkUrl]); | ||||
|       return APKDetails(version, apkUrls); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
| @@ -42,13 +38,4 @@ class Signal implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class SourceForge implements AppSource { | ||||
|   @override | ||||
|   late String host = 'sourceforge.net'; | ||||
| class SourceForge extends AppSource { | ||||
|   SourceForge() { | ||||
|     host = 'sourceforge.net'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
| @@ -21,12 +21,10 @@ class SourceForge implements AppSource { | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/rss?path=/')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var parsedHtml = parse(res.body); | ||||
| @@ -52,9 +50,6 @@ class SourceForge implements AppSource { | ||||
|           apkUrlListAllReleases // This can be used skipped for fallback support later | ||||
|               .where((element) => getVersion(element) == version) | ||||
|               .toList(); | ||||
|       if (apkUrlList.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       return APKDetails(version, apkUrlList); | ||||
|     } else { | ||||
|       throw NoReleasesError(); | ||||
| @@ -66,13 +61,4 @@ class SourceForge implements AppSource { | ||||
|     return AppNames(runtimeType.toString(), | ||||
|         standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = []; | ||||
|  | ||||
|   @override | ||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||
| } | ||||
|   | ||||
| @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; | ||||
|  | ||||
| enum FormItemType { string, bool } | ||||
|  | ||||
| typedef OnValueChanges = void Function(List<String> values, bool valid); | ||||
| typedef OnValueChanges = void Function( | ||||
|     List<String> values, bool valid, bool isBuilding); | ||||
|  | ||||
| class GeneratedFormItem { | ||||
|   late String key; | ||||
|   late String label; | ||||
|   late FormItemType type; | ||||
|   late bool required; | ||||
| @@ -13,6 +15,7 @@ class GeneratedFormItem { | ||||
|   late String id; | ||||
|   late List<Widget> belowWidgets; | ||||
|   late String? hint; | ||||
|   late List<String>? opts; | ||||
|  | ||||
|   GeneratedFormItem( | ||||
|       {this.label = 'Input', | ||||
| @@ -22,7 +25,9 @@ class GeneratedFormItem { | ||||
|       this.additionalValidators = const [], | ||||
|       this.id = 'input', | ||||
|       this.belowWidgets = const [], | ||||
|       this.hint}); | ||||
|       this.hint, | ||||
|       this.opts, | ||||
|       this.key = 'default'}); | ||||
| } | ||||
|  | ||||
| class GeneratedForm extends StatefulWidget { | ||||
| @@ -47,7 +52,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|   List<List<Widget>> rows = []; | ||||
|  | ||||
|   // If any value changes, call this to update the parent with value and validity | ||||
|   void someValueChanged() { | ||||
|   void someValueChanged({bool isBuilding = false}) { | ||||
|     List<String> returnValues = []; | ||||
|     var valid = true; | ||||
|     for (int r = 0; r < values.length; r++) { | ||||
| @@ -62,7 +67,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     widget.onValueChanges(returnValues, valid); | ||||
|     widget.onValueChanges(returnValues, valid, isBuilding); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -75,14 +80,16 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         .map((row) => row.map((e) { | ||||
|               return j < widget.defaultValues.length | ||||
|                   ? widget.defaultValues[j++] | ||||
|                   : ''; | ||||
|                   : e.opts != null | ||||
|                       ? e.opts!.first | ||||
|                       : ''; | ||||
|             }).toList()) | ||||
|         .toList(); | ||||
|  | ||||
|     // Dynamically create form inputs | ||||
|     formInputs = widget.items.asMap().entries.map((row) { | ||||
|       return row.value.asMap().entries.map((e) { | ||||
|         if (e.value.type == FormItemType.string) { | ||||
|         if (e.value.type == FormItemType.string && e.value.opts == null) { | ||||
|           final formFieldKey = GlobalKey<FormFieldState>(); | ||||
|           return TextFormField( | ||||
|             key: formFieldKey, | ||||
| @@ -112,11 +119,29 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               return null; | ||||
|             }, | ||||
|           ); | ||||
|         } else if (e.value.type == FormItemType.string && | ||||
|             e.value.opts != null) { | ||||
|           if (e.value.opts!.isEmpty) { | ||||
|             return const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.'); | ||||
|           } | ||||
|           return DropdownButtonFormField( | ||||
|               decoration: const InputDecoration(labelText: 'Colour'), | ||||
|               value: values[row.key][e.key], | ||||
|               items: e.value.opts! | ||||
|                   .map((e) => DropdownMenuItem(value: e, child: Text(e))) | ||||
|                   .toList(), | ||||
|               onChanged: (value) { | ||||
|                 setState(() { | ||||
|                   values[row.key][e.key] = value ?? e.value.opts!.first; | ||||
|                   someValueChanged(); | ||||
|                 }); | ||||
|               }); | ||||
|         } else { | ||||
|           return Container(); // Some input types added in build | ||||
|         } | ||||
|       }).toList(); | ||||
|     }).toList(); | ||||
|     someValueChanged(isBuilding: true); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -186,3 +211,18 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| String? findGeneratedFormValueByKey( | ||||
|     List<GeneratedFormItem> items, List<String> values, String key) { | ||||
|   var foundIndex = -1; | ||||
|   for (var i = 0; i < items.length; i++) { | ||||
|     if (items[i].key == key) { | ||||
|       foundIndex = i; | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|   if (foundIndex >= 0 && foundIndex < values.length) { | ||||
|     return values[foundIndex]; | ||||
|   } | ||||
|   return null; | ||||
| } | ||||
|   | ||||
| @@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     values = widget.defaultValues; | ||||
|     valid = widget.initValid; | ||||
|     valid = widget.initValid || widget.items.isEmpty; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -46,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|           ), | ||||
|         GeneratedForm( | ||||
|             items: widget.items, | ||||
|             onValueChanges: (values, valid) { | ||||
|               setState(() { | ||||
|             onValueChanges: (values, valid, isBuilding) { | ||||
|               if (isBuilding) { | ||||
|                 this.values = values; | ||||
|                 this.valid = valid; | ||||
|               }); | ||||
|               } else { | ||||
|                 setState(() { | ||||
|                   this.values = values; | ||||
|                   this.valid = valid; | ||||
|                 }); | ||||
|               } | ||||
|             }, | ||||
|             defaultValues: widget.defaultValues) | ||||
|       ]), | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class ObtainiumError { | ||||
|   late String message; | ||||
|   ObtainiumError(this.message); | ||||
|   bool unexpected; | ||||
|   ObtainiumError(this.message, {this.unexpected = false}); | ||||
|   @override | ||||
|   String toString() { | ||||
|     return message; | ||||
| @@ -48,10 +50,14 @@ class IDChangedError extends ObtainiumError { | ||||
|       : super('Downloaded package ID does not match existing App ID'); | ||||
| } | ||||
|  | ||||
| class NotImplementedError extends ObtainiumError { | ||||
|   NotImplementedError() : super('This class has not implemented this function'); | ||||
| } | ||||
|  | ||||
| class MultiAppMultiError extends ObtainiumError { | ||||
|   Map<String, List<String>> content = {}; | ||||
|  | ||||
|   MultiAppMultiError() : super('Multiple Errors Placeholder'); | ||||
|   MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true); | ||||
|  | ||||
|   add(String appId, String string) { | ||||
|     var tempIds = content.remove(string); | ||||
| @@ -71,7 +77,9 @@ class MultiAppMultiError extends ObtainiumError { | ||||
| } | ||||
|  | ||||
| showError(dynamic e, BuildContext context) { | ||||
|   if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) { | ||||
|   Provider.of<LogsProvider>(context, listen: false) | ||||
|       .add(e.toString(), level: LogLevels.error); | ||||
|   if (e is String || (e is ObtainiumError && !e.unexpected)) { | ||||
|     ScaffoldMessenger.of(context).showSnackBar( | ||||
|       SnackBar(content: Text(e.toString())), | ||||
|     ); | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/home.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| @@ -15,7 +16,7 @@ import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||
|  | ||||
| const String currentVersion = '0.6.11'; | ||||
| const String currentVersion = '0.8.1'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @@ -23,12 +24,15 @@ const int bgUpdateCheckAlarmId = 666; | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|   LogsProvider logs = LogsProvider(); | ||||
|   logs.add('Started BG update check task'); | ||||
|   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   await AndroidAlarmManager.initialize(); | ||||
|   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||
|       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||
|       : null; | ||||
|   logs.add('Bg update ignoreAfter is $ignoreAfter'); | ||||
|   var notificationsProvider = NotificationsProvider(); | ||||
|   await notificationsProvider.notify(checkingUpdatesNotification); | ||||
|   try { | ||||
| @@ -40,17 +44,18 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|     DateTime nextIgnoreAfter = DateTime.now(); | ||||
|     String? err; | ||||
|     try { | ||||
|       logs.add('Started actual BG update checking'); | ||||
|       await appsProvider.checkUpdates( | ||||
|           ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); | ||||
|     } catch (e) { | ||||
|       if (e is RateLimitError || e is SocketException) { | ||||
|         AndroidAlarmManager.oneShot( | ||||
|             Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15), | ||||
|             Random().nextInt(pow(2, 31) as int), | ||||
|             bgUpdateCheck, | ||||
|             params: { | ||||
|               'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||
|             }); | ||||
|         var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; | ||||
|         logs.add( | ||||
|             'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes'); | ||||
|         AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), | ||||
|             Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { | ||||
|           'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||
|         }); | ||||
|       } else { | ||||
|         err = e.toString(); | ||||
|       } | ||||
| @@ -74,7 +79,8 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||
|     //       cancelExisting: true); | ||||
|     // } | ||||
|  | ||||
|     logs.add( | ||||
|         'BG update checking found ${newUpdates.length} updates - will notify user if needed'); | ||||
|     if (newUpdates.isNotEmpty) { | ||||
|       notificationsProvider.notify(UpdateNotification(newUpdates)); | ||||
|     } | ||||
| @@ -85,6 +91,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|     notificationsProvider | ||||
|         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||
|   } finally { | ||||
|     logs.add('Finished BG update check task'); | ||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|   } | ||||
| } | ||||
| @@ -102,7 +109,8 @@ void main() async { | ||||
|     providers: [ | ||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||
|       Provider(create: (context) => NotificationsProvider()) | ||||
|       Provider(create: (context) => NotificationsProvider()), | ||||
|       Provider(create: (context) => LogsProvider()) | ||||
|     ], | ||||
|     child: const Obtainium(), | ||||
|   )); | ||||
| @@ -124,17 +132,19 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|   Widget build(BuildContext context) { | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|     LogsProvider logs = context.read<LogsProvider>(); | ||||
|  | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } else { | ||||
|       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||
|       if (isFirstRun) { | ||||
|         logs.add('This is the first ever run of Obtainium'); | ||||
|         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||
|         Permission.notification.request(); | ||||
|         appsProvider.saveApps([ | ||||
|           App( | ||||
|               'dev.imranr.obtainium', | ||||
|               obtainiumId, | ||||
|               'https://github.com/ImranR98/Obtainium', | ||||
|               'ImranR98', | ||||
|               'Obtainium', | ||||
| @@ -143,11 +153,17 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|               [], | ||||
|               0, | ||||
|               ['true'], | ||||
|               null) | ||||
|               null, | ||||
|               false, | ||||
|               false) | ||||
|         ]); | ||||
|       } | ||||
|       // Register the background update task according to the user's setting | ||||
|       if (existingUpdateInterval != settingsProvider.updateInterval) { | ||||
|         if (existingUpdateInterval != -1) { | ||||
|           logs.add( | ||||
|               'Setting update interval to ${settingsProvider.updateInterval}'); | ||||
|         } | ||||
|         existingUpdateInterval = settingsProvider.updateInterval; | ||||
|         if (existingUpdateInterval == 0) { | ||||
|           AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); | ||||
|   | ||||
| @@ -12,40 +12,42 @@ class GitHubStars implements MassAppUrlSource { | ||||
|   @override | ||||
|   late List<String> requiredArgs = ['Username']; | ||||
|  | ||||
|   Future<List<String>> getOnePageOfUserStarredUrls( | ||||
|   Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions( | ||||
|       String username, int page) async { | ||||
|     Response res = await get(Uri.parse( | ||||
|         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); | ||||
|     if (res.statusCode == 200) { | ||||
|       return (jsonDecode(res.body) as List<dynamic>) | ||||
|           .map((e) => e['html_url'] as String) | ||||
|           .toList(); | ||||
|     } else { | ||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||
|         throw RateLimitError( | ||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||
|                     60000000) | ||||
|                 .round()); | ||||
|       Map<String, String> urlsWithDescriptions = {}; | ||||
|       for (var e in (jsonDecode(res.body) as List<dynamic>)) { | ||||
|         urlsWithDescriptions.addAll({ | ||||
|           e['html_url'] as String: e['description'] != null | ||||
|               ? e['description'] as String | ||||
|               : 'No description' | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       throw ObtainiumError('Unable to find user\'s starred repos'); | ||||
|       return urlsWithDescriptions; | ||||
|     } else { | ||||
|       var gh = GitHub(); | ||||
|       gh.rateLimitErrorCheck(res); | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<List<String>> getUrls(List<String> args) async { | ||||
|   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async { | ||||
|     if (args.length != requiredArgs.length) { | ||||
|       throw ObtainiumError('Wrong number of arguments provided'); | ||||
|     } | ||||
|     List<String> urls = []; | ||||
|     Map<String, String> urlsWithDescriptions = {}; | ||||
|     var page = 1; | ||||
|     while (true) { | ||||
|       var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); | ||||
|       urls.addAll(pageUrls); | ||||
|       var pageUrls = | ||||
|           await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++); | ||||
|       urlsWithDescriptions.addAll(pageUrls); | ||||
|       if (pageUrls.length < 100) { | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|     return urls; | ||||
|     return urlsWithDescriptions; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/pages/import_export.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| @@ -21,13 +23,117 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|   bool gettingAppInfo = false; | ||||
|  | ||||
|   String userInput = ''; | ||||
|   String searchQuery = ''; | ||||
|   AppSource? pickedSource; | ||||
|   List<String> additionalData = []; | ||||
|   bool validAdditionalData = true; | ||||
|   List<String> sourceSpecificAdditionalData = []; | ||||
|   bool sourceSpecificDataIsValid = true; | ||||
|   List<String> otherAdditionalData = []; | ||||
|   bool otherAdditionalDataIsValid = true; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|  | ||||
|     changeUserInput(String input, bool valid, bool isBuilding) { | ||||
|       userInput = input; | ||||
|       fn() { | ||||
|         var source = valid ? sourceProvider.getSource(userInput) : null; | ||||
|         if (pickedSource != source) { | ||||
|           pickedSource = source; | ||||
|           sourceSpecificAdditionalData = | ||||
|               source != null ? source.additionalSourceAppSpecificDefaults : []; | ||||
|           sourceSpecificDataIsValid = source != null | ||||
|               ? sourceProvider.ifSourceAppsRequireAdditionalData(source) | ||||
|               : true; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (isBuilding) { | ||||
|         fn(); | ||||
|       } else { | ||||
|         setState(() { | ||||
|           fn(); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     addApp({bool resetUserInputAfter = false}) async { | ||||
|       setState(() { | ||||
|         gettingAppInfo = true; | ||||
|       }); | ||||
|       var settingsProvider = context.read<SettingsProvider>(); | ||||
|       () async { | ||||
|         var userPickedTrackOnly = findGeneratedFormValueByKey( | ||||
|                 pickedSource!.additionalAppSpecificSourceAgnosticFormItems, | ||||
|                 otherAdditionalData, | ||||
|                 'trackOnlyFormItemKey') == | ||||
|             'true'; | ||||
|         var cont = true; | ||||
|         if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && | ||||
|             await showDialog( | ||||
|                     context: context, | ||||
|                     builder: (BuildContext ctx) { | ||||
|                       return GeneratedFormModal( | ||||
|                         title: | ||||
|                             '${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only', | ||||
|                         items: const [], | ||||
|                         defaultValues: const [], | ||||
|                         message: | ||||
|                             '${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.', | ||||
|                       ); | ||||
|                     }) == | ||||
|                 null) { | ||||
|           cont = false; | ||||
|         } | ||||
|         if (cont) { | ||||
|           HapticFeedback.selectionClick(); | ||||
|           var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; | ||||
|           App app = await sourceProvider.getApp( | ||||
|               pickedSource!, userInput, sourceSpecificAdditionalData, | ||||
|               trackOnly: trackOnly); | ||||
|           if (!trackOnly) { | ||||
|             await settingsProvider.getInstallPermission(); | ||||
|           } | ||||
|           // Only download the APK here if you need to for the package ID | ||||
|           if (sourceProvider.isTempId(app.id) && !app.trackOnly) { | ||||
|             // ignore: use_build_context_synchronously | ||||
|             var apkUrl = await appsProvider.confirmApkUrl(app, context); | ||||
|             if (apkUrl == null) { | ||||
|               throw ObtainiumError('Cancelled'); | ||||
|             } | ||||
|             app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); | ||||
|             var downloadedApk = await appsProvider.downloadApp(app); | ||||
|             app.id = downloadedApk.appId; | ||||
|           } | ||||
|           if (appsProvider.apps.containsKey(app.id)) { | ||||
|             throw ObtainiumError('App already added'); | ||||
|           } | ||||
|           if (app.trackOnly) { | ||||
|             app.installedVersion = app.latestVersion; | ||||
|           } | ||||
|           await appsProvider.saveApps([app]); | ||||
|  | ||||
|           return app; | ||||
|         } | ||||
|       }() | ||||
|           .then((app) { | ||||
|         if (app != null) { | ||||
|           Navigator.push(context, | ||||
|               MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); | ||||
|         } | ||||
|       }).catchError((e) { | ||||
|         showError(e, context); | ||||
|       }).whenComplete(() { | ||||
|         setState(() { | ||||
|           gettingAppInfo = false; | ||||
|           if (resetUserInputAfter) { | ||||
|             changeUserInput('', false, true); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -66,24 +172,9 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                           ]) | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid) { | ||||
|                                     setState(() { | ||||
|                                       userInput = values[0]; | ||||
|                                       var source = valid | ||||
|                                           ? sourceProvider.getSource(userInput) | ||||
|                                           : null; | ||||
|                                       if (pickedSource != source) { | ||||
|                                         pickedSource = source; | ||||
|                                         additionalData = source != null | ||||
|                                             ? source.additionalDataDefaults | ||||
|                                             : []; | ||||
|                                         validAdditionalData = source != null | ||||
|                                             ? sourceProvider | ||||
|                                                 .ifSourceAppsRequireAdditionalData( | ||||
|                                                     source) | ||||
|                                             : true; | ||||
|                                       } | ||||
|                                     }); | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     changeUserInput( | ||||
|                                         values[0], valid, isBuilding); | ||||
|                                   }, | ||||
|                                   defaultValues: const [])), | ||||
|                           const SizedBox( | ||||
| @@ -94,68 +185,115 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               : ElevatedButton( | ||||
|                                   onPressed: gettingAppInfo || | ||||
|                                           pickedSource == null || | ||||
|                                           (pickedSource!.additionalDataFormItems | ||||
|                                           (pickedSource! | ||||
|                                                   .additionalSourceAppSpecificFormItems | ||||
|                                                   .isNotEmpty && | ||||
|                                               !validAdditionalData) | ||||
|                                               !sourceSpecificDataIsValid) || | ||||
|                                           (pickedSource! | ||||
|                                                   .additionalAppSpecificSourceAgnosticDefaults | ||||
|                                                   .isNotEmpty && | ||||
|                                               !otherAdditionalDataIsValid) | ||||
|                                       ? null | ||||
|                                       : () async { | ||||
|                                           setState(() { | ||||
|                                             gettingAppInfo = true; | ||||
|                                           }); | ||||
|                                           var appsProvider = | ||||
|                                               context.read<AppsProvider>(); | ||||
|                                           var settingsProvider = | ||||
|                                               context.read<SettingsProvider>(); | ||||
|                                           () async { | ||||
|                                             HapticFeedback.selectionClick(); | ||||
|                                             App app = | ||||
|                                                 await sourceProvider.getApp( | ||||
|                                                     pickedSource!, | ||||
|                                                     userInput, | ||||
|                                                     additionalData); | ||||
|                                             await settingsProvider | ||||
|                                                 .getInstallPermission(); | ||||
|                                             // ignore: use_build_context_synchronously | ||||
|                                             var apkUrl = await appsProvider | ||||
|                                                 .confirmApkUrl(app, context); | ||||
|                                             if (apkUrl == null) { | ||||
|                                               throw ObtainiumError('Cancelled'); | ||||
|                                             } | ||||
|                                             app.preferredApkIndex = | ||||
|                                                 app.apkUrls.indexOf(apkUrl); | ||||
|                                             var downloadedApk = | ||||
|                                                 await appsProvider | ||||
|                                                     .downloadApp(app); | ||||
|                                             app.id = downloadedApk.appId; | ||||
|                                             if (appsProvider.apps | ||||
|                                                 .containsKey(app.id)) { | ||||
|                                               throw ObtainiumError( | ||||
|                                                   'App already added'); | ||||
|                                             } | ||||
|                                             await appsProvider.saveApps([app]); | ||||
|  | ||||
|                                             return app; | ||||
|                                           }() | ||||
|                                               .then((app) { | ||||
|                                             Navigator.push( | ||||
|                                                 context, | ||||
|                                                 MaterialPageRoute( | ||||
|                                                     builder: (context) => | ||||
|                                                         AppPage( | ||||
|                                                             appId: app.id))); | ||||
|                                           }).catchError((e) { | ||||
|                                             showError(e, context); | ||||
|                                           }).whenComplete(() { | ||||
|                                             setState(() { | ||||
|                                               gettingAppInfo = false; | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                       : addApp, | ||||
|                                   child: const Text('Add')) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (sourceProvider.sources | ||||
|                               .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|                           pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                       if (sourceProvider.sources | ||||
|                               .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|                           pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                               child: GeneratedForm( | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: 'Search (Some Sources Only)', | ||||
|                                           required: false), | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     if (values.isNotEmpty && valid) { | ||||
|                                       setState(() { | ||||
|                                         searchQuery = values[0].trim(); | ||||
|                                       }); | ||||
|                                     } | ||||
|                                   }, | ||||
|                                   defaultValues: const ['']), | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               width: 16, | ||||
|                             ), | ||||
|                             ElevatedButton( | ||||
|                                 onPressed: searchQuery.isEmpty || gettingAppInfo | ||||
|                                     ? null | ||||
|                                     : () { | ||||
|                                         Future.wait(sourceProvider.sources | ||||
|                                                 .where((e) => e.canSearch) | ||||
|                                                 .map((e) => | ||||
|                                                     e.search(searchQuery))) | ||||
|                                             .then((results) async { | ||||
|                                           // Interleave results instead of simple reduce | ||||
|                                           Map<String, String> res = {}; | ||||
|                                           var si = 0; | ||||
|                                           var done = false; | ||||
|                                           while (!done) { | ||||
|                                             done = true; | ||||
|                                             for (var r in results) { | ||||
|                                               if (r.length > si) { | ||||
|                                                 done = false; | ||||
|                                                 res.addEntries( | ||||
|                                                     [r.entries.elementAt(si)]); | ||||
|                                               } | ||||
|                                             } | ||||
|                                             si++; | ||||
|                                           } | ||||
|                                           List<String>? selectedUrls = res | ||||
|                                                   .isEmpty | ||||
|                                               ? [] | ||||
|                                               : await showDialog<List<String>?>( | ||||
|                                                   context: context, | ||||
|                                                   builder: (BuildContext ctx) { | ||||
|                                                     return UrlSelectionModal( | ||||
|                                                       urlsWithDescriptions: res, | ||||
|                                                       selectedByDefault: false, | ||||
|                                                       onlyOneSelectionAllowed: | ||||
|                                                           true, | ||||
|                                                     ); | ||||
|                                                   }); | ||||
|                                           if (selectedUrls != null && | ||||
|                                               selectedUrls.isNotEmpty) { | ||||
|                                             changeUserInput( | ||||
|                                                 selectedUrls[0], true, true); | ||||
|                                             addApp(resetUserInputAfter: true); | ||||
|                                           } | ||||
|                                         }).catchError((e) { | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       }, | ||||
|                                 child: const Text('Search')) | ||||
|                           ], | ||||
|                         ), | ||||
|                       if (pickedSource != null && | ||||
|                           pickedSource!.additionalDataDefaults.isNotEmpty) | ||||
|                           (pickedSource!.additionalSourceAppSpecificDefaults | ||||
|                                   .isNotEmpty || | ||||
|                               pickedSource! | ||||
|                                   .additionalAppSpecificSourceAgnosticFormItems | ||||
|                                   .where((e) => pickedSource!.enforceTrackOnly | ||||
|                                       ? e.key != 'trackOnlyFormItemKey' | ||||
|                                       : true) | ||||
|                                   .map((e) => [e]) | ||||
|                                   .isNotEmpty)) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                           children: [ | ||||
| @@ -171,22 +309,51 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             if (pickedSource! | ||||
|                                 .additionalDataFormItems.isNotEmpty) | ||||
|                                 .additionalSourceAppSpecificFormItems | ||||
|                                 .isNotEmpty) | ||||
|                               GeneratedForm( | ||||
|                                   items: pickedSource!.additionalDataFormItems, | ||||
|                                   onValueChanges: (values, valid) { | ||||
|                                     setState(() { | ||||
|                                       additionalData = values; | ||||
|                                       validAdditionalData = valid; | ||||
|                                     }); | ||||
|                                   items: pickedSource! | ||||
|                                       .additionalSourceAppSpecificFormItems, | ||||
|                                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     if (isBuilding) { | ||||
|                                       sourceSpecificAdditionalData = values; | ||||
|                                       sourceSpecificDataIsValid = valid; | ||||
|                                     } else { | ||||
|                                       setState(() { | ||||
|                                         sourceSpecificAdditionalData = values; | ||||
|                                         sourceSpecificDataIsValid = valid; | ||||
|                                       }); | ||||
|                                     } | ||||
|                                   }, | ||||
|                                   defaultValues: | ||||
|                                       pickedSource!.additionalDataDefaults), | ||||
|                                   defaultValues: pickedSource! | ||||
|                                       .additionalSourceAppSpecificDefaults), | ||||
|                             if (pickedSource! | ||||
|                                 .additionalDataFormItems.isNotEmpty) | ||||
|                                 .additionalAppSpecificSourceAgnosticDefaults | ||||
|                                 .isNotEmpty) | ||||
|                               const SizedBox( | ||||
|                                 height: 8, | ||||
|                               ), | ||||
|                             GeneratedForm( | ||||
|                                 items: pickedSource! | ||||
|                                     .additionalAppSpecificSourceAgnosticFormItems | ||||
|                                     .where((e) => pickedSource!.enforceTrackOnly | ||||
|                                         ? e.key != 'trackOnlyFormItemKey' | ||||
|                                         : true) | ||||
|                                     .map((e) => [e]) | ||||
|                                     .toList(), | ||||
|                                 onValueChanges: (values, valid, isBuilding) { | ||||
|                                   if (isBuilding) { | ||||
|                                     otherAdditionalData = values; | ||||
|                                     otherAdditionalDataIsValid = valid; | ||||
|                                   } else { | ||||
|                                     setState(() { | ||||
|                                       otherAdditionalData = values; | ||||
|                                       otherAdditionalDataIsValid = valid; | ||||
|                                     }); | ||||
|                                   } | ||||
|                                 }, | ||||
|                                 defaultValues: pickedSource! | ||||
|                                     .additionalAppSpecificSourceAgnosticDefaults), | ||||
|                           ], | ||||
|                         ) | ||||
|                       else | ||||
| @@ -195,22 +362,24 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                                 children: [ | ||||
|                               const SizedBox( | ||||
|                                 height: 48, | ||||
|                               ), | ||||
|                               const Text( | ||||
|                                 'Supported Sources:', | ||||
|                               ), | ||||
|                               const SizedBox( | ||||
|                                 height: 8, | ||||
|                               ), | ||||
|                               ...sourceProvider | ||||
|                                   .getSourceHosts() | ||||
|                               ...sourceProvider.sources | ||||
|                                   .map((e) => GestureDetector( | ||||
|                                       onTap: () { | ||||
|                                         launchUrlString('https://$e', | ||||
|                                         launchUrlString('https://${e.host}', | ||||
|                                             mode: | ||||
|                                                 LaunchMode.externalApplication); | ||||
|                                       }, | ||||
|                                       child: Text( | ||||
|                                         e, | ||||
|                                         '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}', | ||||
|                                         style: const TextStyle( | ||||
|                                             decoration: | ||||
|                                                 TextDecoration.underline, | ||||
| @@ -218,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                       ))) | ||||
|                                   .toList() | ||||
|                             ])), | ||||
|                       const SizedBox( | ||||
|                         height: 8, | ||||
|                       ), | ||||
|                     ])), | ||||
|           ) | ||||
|         ])); | ||||
|   | ||||
| @@ -106,7 +106,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Installed Version: ${app?.app.installedVersion ?? 'None'}', | ||||
|                           'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.bodyLarge, | ||||
|                         ), | ||||
| @@ -140,6 +140,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                       children: [ | ||||
|                         if (app?.app.installedVersion != null && | ||||
|                             app?.app.trackOnly == false && | ||||
|                             app?.app.installedVersion != app?.app.latestVersion) | ||||
|                           IconButton( | ||||
|                               onPressed: app?.downloadProgress != null | ||||
| @@ -183,7 +184,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                               tooltip: 'Mark as Updated', | ||||
|                               icon: const Icon(Icons.done)), | ||||
|                         if (source != null && | ||||
|                             source.additionalDataFormItems.isNotEmpty) | ||||
|                             source.additionalSourceAppSpecificFormItems | ||||
|                                 .isNotEmpty) | ||||
|                           IconButton( | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
| @@ -194,11 +196,11 @@ class _AppPageState extends State<AppPage> { | ||||
|                                             return GeneratedFormModal( | ||||
|                                                 title: 'Additional Options', | ||||
|                                                 items: source | ||||
|                                                     .additionalDataFormItems, | ||||
|                                                     .additionalSourceAppSpecificFormItems, | ||||
|                                                 defaultValues: app != null | ||||
|                                                     ? app.app.additionalData | ||||
|                                                     : source | ||||
|                                                         .additionalDataDefaults); | ||||
|                                                         .additionalSourceAppSpecificDefaults); | ||||
|                                           }).then((values) { | ||||
|                                         if (app != null && values != null) { | ||||
|                                           var changedApp = app.app; | ||||
| @@ -221,21 +223,33 @@ class _AppPageState extends State<AppPage> { | ||||
|                                         !appsProvider.areDownloadsRunning() | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
|                                         appsProvider | ||||
|                                             .downloadAndInstallLatestApps( | ||||
|                                                 [app!.app.id], | ||||
|                                                 context).then((res) { | ||||
|                                           if (res.isNotEmpty && mounted) { | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                         () async { | ||||
|                                           if (app?.app.trackOnly != true) { | ||||
|                                             await settingsProvider | ||||
|                                                 .getInstallPermission(); | ||||
|                                           } | ||||
|                                         }() | ||||
|                                             .then((value) { | ||||
|                                           appsProvider | ||||
|                                               .downloadAndInstallLatestApps( | ||||
|                                                   [app!.app.id], | ||||
|                                                   context).then((res) { | ||||
|                                             if (res.isNotEmpty && mounted) { | ||||
|                                               Navigator.of(context).pop(); | ||||
|                                             } | ||||
|                                           }); | ||||
|                                         }).catchError((e) { | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
|                                 child: Text(app?.app.installedVersion == null | ||||
|                                     ? 'Install' | ||||
|                                     : 'Update'))), | ||||
|                                     ? app?.app.trackOnly == false | ||||
|                                         ? 'Install' | ||||
|                                         : 'Mark Installed' | ||||
|                                     : app?.app.trackOnly == false | ||||
|                                         ? 'Update' | ||||
|                                         : 'Mark Updated'))), | ||||
|                         const SizedBox(width: 16.0), | ||||
|                         ElevatedButton( | ||||
|                           onPressed: app?.downloadProgress != null | ||||
|   | ||||
| @@ -23,24 +23,24 @@ class AppsPageState extends State<AppsPage> { | ||||
|   AppsFilter? filter; | ||||
|   var updatesOnlyFilter = | ||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||
|   Set<String> selectedIds = {}; | ||||
|   Set<App> selectedApps = {}; | ||||
|   DateTime? refreshingSince; | ||||
|  | ||||
|   clearSelected() { | ||||
|     if (selectedIds.isNotEmpty) { | ||||
|     if (selectedApps.isNotEmpty) { | ||||
|       setState(() { | ||||
|         selectedIds.clear(); | ||||
|         selectedApps.clear(); | ||||
|       }); | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   selectThese(List<String> appIds) { | ||||
|     if (selectedIds.isEmpty) { | ||||
|   selectThese(List<App> apps) { | ||||
|     if (selectedApps.isEmpty) { | ||||
|       setState(() { | ||||
|         for (var a in appIds) { | ||||
|           selectedIds.add(a); | ||||
|         for (var a in apps) { | ||||
|           selectedApps.add(a); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
| @@ -54,16 +54,16 @@ class AppsPageState extends State<AppsPage> { | ||||
|     var currentFilterIsUpdatesOnly = | ||||
|         filter?.isIdenticalTo(updatesOnlyFilter) ?? false; | ||||
|  | ||||
|     selectedIds = selectedIds | ||||
|         .where((element) => sortedApps.map((e) => e.app.id).contains(element)) | ||||
|     selectedApps = selectedApps | ||||
|         .where((element) => sortedApps.map((e) => e.app).contains(element)) | ||||
|         .toSet(); | ||||
|  | ||||
|     toggleAppSelected(String appId) { | ||||
|     toggleAppSelected(App app) { | ||||
|       setState(() { | ||||
|         if (selectedIds.contains(appId)) { | ||||
|           selectedIds.remove(appId); | ||||
|         if (selectedApps.contains(app)) { | ||||
|           selectedApps.remove(app); | ||||
|         } else { | ||||
|           selectedIds.add(appId); | ||||
|           selectedApps.add(app); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
| @@ -124,17 +124,33 @@ class AppsPageState extends State<AppsPage> { | ||||
|     var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = existingUpdates | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|         .where((element) => selectedApps.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedIds.contains(element)) | ||||
|             : selectedApps.map((e) => e.id).contains(element)) | ||||
|         .toList(); | ||||
|     var newInstallIdsAllOrSelected = appsProvider | ||||
|         .findExistingUpdates(nonInstalledOnly: true) | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|         .where((element) => selectedApps.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedIds.contains(element)) | ||||
|             : selectedApps.map((e) => e.id).contains(element)) | ||||
|         .toList(); | ||||
|  | ||||
|     List<String> trackOnlyUpdateIdsAllOrSelected = []; | ||||
|     existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { | ||||
|       if (appsProvider.apps[id]!.app.trackOnly) { | ||||
|         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||
|         return false; | ||||
|       } | ||||
|       return true; | ||||
|     }).toList(); | ||||
|     newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { | ||||
|       if (appsProvider.apps[id]!.app.trackOnly) { | ||||
|         trackOnlyUpdateIdsAllOrSelected.add(id); | ||||
|         return false; | ||||
|       } | ||||
|       return true; | ||||
|     }).toList(); | ||||
|  | ||||
|     if (settingsProvider.pinUpdates) { | ||||
|       var temp = []; | ||||
|       sortedApps = sortedApps.where((sa) { | ||||
| @@ -147,6 +163,17 @@ class AppsPageState extends State<AppsPage> { | ||||
|       sortedApps = [...temp, ...sortedApps]; | ||||
|     } | ||||
|  | ||||
|     var tempPinned = []; | ||||
|     var tempNotPinned = []; | ||||
|     for (var a in sortedApps) { | ||||
|       if (a.app.pinned) { | ||||
|         tempPinned.add(a); | ||||
|       } else { | ||||
|         tempNotPinned.add(a); | ||||
|       } | ||||
|     } | ||||
|     sortedApps = [...tempPinned, ...tempNotPinned]; | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
| @@ -192,11 +219,16 @@ class AppsPageState extends State<AppsPage> { | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
|               return ListTile( | ||||
|                 selectedTileColor: | ||||
|                     Theme.of(context).colorScheme.primary.withOpacity(0.1), | ||||
|                 selected: selectedIds.contains(sortedApps[index].app.id), | ||||
|                 tileColor: sortedApps[index].app.pinned | ||||
|                     ? Colors.grey.withOpacity(0.1) | ||||
|                     : Colors.transparent, | ||||
|                 selectedTileColor: Theme.of(context) | ||||
|                     .colorScheme | ||||
|                     .primary | ||||
|                     .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), | ||||
|                 selected: selectedApps.contains(sortedApps[index].app), | ||||
|                 onLongPress: () { | ||||
|                   toggleAppSelected(sortedApps[index].app.id); | ||||
|                   toggleAppSelected(sortedApps[index].app); | ||||
|                 }, | ||||
|                 leading: sortedApps[index].installedInfo != null | ||||
|                     ? Image.memory( | ||||
| @@ -204,9 +236,19 @@ class AppsPageState extends State<AppsPage> { | ||||
|                         gaplessPlayback: true, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 title: Text(sortedApps[index].installedInfo?.name ?? | ||||
|                     sortedApps[index].app.name), | ||||
|                 subtitle: Text('By ${sortedApps[index].app.author}'), | ||||
|                 title: Text( | ||||
|                   sortedApps[index].installedInfo?.name ?? | ||||
|                       sortedApps[index].app.name, | ||||
|                   style: TextStyle( | ||||
|                       fontWeight: sortedApps[index].app.pinned | ||||
|                           ? FontWeight.bold | ||||
|                           : FontWeight.normal), | ||||
|                 ), | ||||
|                 subtitle: Text('By ${sortedApps[index].app.author}', | ||||
|                     style: TextStyle( | ||||
|                         fontWeight: sortedApps[index].app.pinned | ||||
|                             ? FontWeight.bold | ||||
|                             : FontWeight.normal)), | ||||
|                 trailing: sortedApps[index].downloadProgress != null | ||||
|                     ? Text( | ||||
|                         'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||
| @@ -219,7 +261,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             children: [ | ||||
|                               Text(appsProvider.areDownloadsRunning() | ||||
|                                   ? 'Please Wait...' | ||||
|                                   : 'Update Available'), | ||||
|                                   : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'), | ||||
|                               SourceProvider() | ||||
|                                           .getSource(sortedApps[index].app.url) | ||||
|                                           .changeLogPageFromStandardUrl( | ||||
| @@ -250,14 +292,13 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             child: SizedBox( | ||||
|                                 width: 80, | ||||
|                                 child: Text( | ||||
|                                   sortedApps[index].app.installedVersion ?? | ||||
|                                       'Not Installed', | ||||
|                                   '${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}', | ||||
|                                   overflow: TextOverflow.fade, | ||||
|                                   textAlign: TextAlign.end, | ||||
|                                 )))), | ||||
|                 onTap: () { | ||||
|                   if (selectedIds.isNotEmpty) { | ||||
|                     toggleAppSelected(sortedApps[index].app.id); | ||||
|                   if (selectedApps.isNotEmpty) { | ||||
|                     toggleAppSelected(sortedApps[index].app); | ||||
|                   } else { | ||||
|                     Navigator.push( | ||||
|                       context, | ||||
| @@ -275,25 +316,25 @@ class AppsPageState extends State<AppsPage> { | ||||
|           children: [ | ||||
|             IconButton( | ||||
|                 onPressed: () { | ||||
|                   selectedIds.isEmpty | ||||
|                       ? selectThese(sortedApps.map((e) => e.app.id).toList()) | ||||
|                   selectedApps.isEmpty | ||||
|                       ? selectThese(sortedApps.map((e) => e.app).toList()) | ||||
|                       : clearSelected(); | ||||
|                 }, | ||||
|                 icon: Icon( | ||||
|                   selectedIds.isEmpty | ||||
|                   selectedApps.isEmpty | ||||
|                       ? Icons.select_all_outlined | ||||
|                       : Icons.deselect_outlined, | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                 ), | ||||
|                 tooltip: selectedIds.isEmpty | ||||
|                 tooltip: selectedApps.isEmpty | ||||
|                     ? 'Select All' | ||||
|                     : 'Deselect ${selectedIds.length.toString()}'), | ||||
|                     : 'Deselect ${selectedApps.length.toString()}'), | ||||
|             const VerticalDivider(), | ||||
|             Expanded( | ||||
|                 child: Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|               children: [ | ||||
|                 selectedIds.isEmpty | ||||
|                 selectedApps.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
|                         visualDensity: VisualDensity.compact, | ||||
| @@ -307,11 +348,12 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                   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.', | ||||
|                                       '${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.', | ||||
|                                 ); | ||||
|                               }).then((values) { | ||||
|                             if (values != null) { | ||||
|                               appsProvider.removeApps(selectedIds.toList()); | ||||
|                               appsProvider.removeApps( | ||||
|                                   selectedApps.map((e) => e.id).toList()); | ||||
|                             } | ||||
|                           }); | ||||
|                         }, | ||||
| @@ -322,49 +364,77 @@ class AppsPageState extends State<AppsPage> { | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     onPressed: appsProvider.areDownloadsRunning() || | ||||
|                             (existingUpdateIdsAllOrSelected.isEmpty && | ||||
|                                 newInstallIdsAllOrSelected.isEmpty) | ||||
|                                 newInstallIdsAllOrSelected.isEmpty && | ||||
|                                 trackOnlyUpdateIdsAllOrSelected.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) | ||||
|                               ]); | ||||
|                             List<GeneratedFormItem> formInputs = []; | ||||
|                             List<String> defaultValues = []; | ||||
|                             if (existingUpdateIdsAllOrSelected.isNotEmpty) { | ||||
|                               formInputs.add(GeneratedFormItem( | ||||
|                                   label: | ||||
|                                       'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', | ||||
|                                   type: FormItemType.bool, | ||||
|                                   key: 'updates')); | ||||
|                               defaultValues.add('true'); | ||||
|                             } | ||||
|                             if (newInstallIdsAllOrSelected.isNotEmpty) { | ||||
|                               formInputs.add(GeneratedFormItem( | ||||
|                                   label: | ||||
|                                       'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', | ||||
|                                   type: FormItemType.bool, | ||||
|                                   key: 'installs')); | ||||
|                               defaultValues | ||||
|                                   .add(defaultValues.isEmpty ? 'true' : ''); | ||||
|                             } | ||||
|                             if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { | ||||
|                               formInputs.add(GeneratedFormItem( | ||||
|                                   label: | ||||
|                                       'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated', | ||||
|                                   type: FormItemType.bool, | ||||
|                                   key: 'trackonlies')); | ||||
|                               defaultValues | ||||
|                                   .add(defaultValues.isEmpty ? 'true' : ''); | ||||
|                             } | ||||
|                             showDialog<List<String>?>( | ||||
|                                 context: context, | ||||
|                                 builder: (BuildContext ctx) { | ||||
|                                   var totalApps = existingUpdateIdsAllOrSelected | ||||
|                                           .length + | ||||
|                                       newInstallIdsAllOrSelected.length + | ||||
|                                       trackOnlyUpdateIdsAllOrSelected.length; | ||||
|                                   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: [ | ||||
|                                       'true', | ||||
|                                       existingUpdateIdsAllOrSelected.isEmpty | ||||
|                                           ? 'true' | ||||
|                                           : '' | ||||
|                                     ], | ||||
|                                         'Change $totalApps App${totalApps == 1 ? '' : 's'}', | ||||
|                                     items: formInputs.map((e) => [e]).toList(), | ||||
|                                     defaultValues: defaultValues, | ||||
|                                     initValid: true, | ||||
|                                   ); | ||||
|                                 }).then((values) { | ||||
|                               if (values != null) { | ||||
|                                 bool shouldInstallUpdates = values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = values[1] == 'true'; | ||||
|                                 settingsProvider | ||||
|                                     .getInstallPermission() | ||||
|                                 if (values.isEmpty) { | ||||
|                                   values = defaultValues; | ||||
|                                 } | ||||
|                                 bool shouldInstallUpdates = | ||||
|                                     findGeneratedFormValueByKey( | ||||
|                                             formInputs, values, 'updates') == | ||||
|                                         'true'; | ||||
|                                 bool shouldInstallNew = | ||||
|                                     findGeneratedFormValueByKey( | ||||
|                                             formInputs, values, 'installs') == | ||||
|                                         'true'; | ||||
|                                 bool shouldMarkTrackOnlies = | ||||
|                                     findGeneratedFormValueByKey(formInputs, | ||||
|                                             values, 'trackonlies') == | ||||
|                                         'true'; | ||||
|                                 (() async { | ||||
|                                   if (shouldInstallNew || | ||||
|                                       shouldInstallUpdates) { | ||||
|                                     await settingsProvider | ||||
|                                         .getInstallPermission(); | ||||
|                                   } | ||||
|                                 })() | ||||
|                                     .then((_) { | ||||
|                                   List<String> toInstall = []; | ||||
|                                   if (shouldInstallUpdates) { | ||||
| @@ -375,6 +445,10 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                     toInstall | ||||
|                                         .addAll(newInstallIdsAllOrSelected); | ||||
|                                   } | ||||
|                                   if (shouldMarkTrackOnlies) { | ||||
|                                     toInstall.addAll( | ||||
|                                         trackOnlyUpdateIdsAllOrSelected); | ||||
|                                   } | ||||
|                                   appsProvider | ||||
|                                       .downloadAndInstallLatestApps( | ||||
|                                           toInstall, context) | ||||
| @@ -386,11 +460,11 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             }); | ||||
|                           }, | ||||
|                     tooltip: | ||||
|                         'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', | ||||
|                         'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps', | ||||
|                     icon: const Icon( | ||||
|                       Icons.file_download_outlined, | ||||
|                     )), | ||||
|                 selectedIds.isEmpty | ||||
|                 selectedApps.isEmpty | ||||
|                     ? const SizedBox() | ||||
|                     : IconButton( | ||||
|                         visualDensity: VisualDensity.compact, | ||||
| @@ -419,7 +493,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                       ctx) { | ||||
|                                                                 return AlertDialog( | ||||
|                                                                   title: Text( | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Updated?'), | ||||
|                                                                       'Mark ${selectedApps.length} Selected Apps as Updated?'), | ||||
|                                                                   content: | ||||
|                                                                       const Text( | ||||
|                                                                           'Only applies to installed but out of date Apps.'), | ||||
| @@ -438,9 +512,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                           HapticFeedback | ||||
|                                                                               .selectionClick(); | ||||
|                                                                           appsProvider | ||||
|                                                                               .saveApps(selectedIds.map((e) { | ||||
|                                                                             var a = | ||||
|                                                                                 appsProvider.apps[e]!.app; | ||||
|                                                                               .saveApps(selectedApps.map((a) { | ||||
|                                                                             if (a.installedVersion != | ||||
|                                                                                 null) { | ||||
|                                                                               a.installedVersion = a.latestVersion; | ||||
| @@ -455,27 +527,84 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                             'Yes')) | ||||
|                                                                   ], | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                               }).whenComplete(() { | ||||
|                                                             Navigator.of( | ||||
|                                                                     context) | ||||
|                                                                 .pop(); | ||||
|                                                           }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Updated', | ||||
|                                               icon: const Icon(Icons.done)), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
|                                               var pinStatus = selectedApps | ||||
|                                                   .where((element) => | ||||
|                                                       element.pinned) | ||||
|                                                   .isEmpty; | ||||
|                                               appsProvider.saveApps( | ||||
|                                                   selectedApps.map((e) { | ||||
|                                                 e.pinned = pinStatus; | ||||
|                                                 return e; | ||||
|                                               }).toList()); | ||||
|                                               Navigator.of(context).pop(); | ||||
|                                             }, | ||||
|                                             tooltip: | ||||
|                                                 '${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top', | ||||
|                                             icon: Icon(selectedApps | ||||
|                                                     .where((element) => | ||||
|                                                         element.pinned) | ||||
|                                                     .isEmpty | ||||
|                                                 ? Icons.bookmark_outline_rounded | ||||
|                                                 : Icons | ||||
|                                                     .bookmark_remove_outlined), | ||||
|                                           ), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
|                                               String urls = ''; | ||||
|                                               for (var id in selectedIds) { | ||||
|                                                 urls += | ||||
|                                                     '${appsProvider.apps[id]!.app.url}\n'; | ||||
|                                               for (var a in selectedApps) { | ||||
|                                                 urls += '${a.url}\n'; | ||||
|                                               } | ||||
|                                               urls = urls.substring( | ||||
|                                                   0, urls.length - 1); | ||||
|                                               Share.share(urls, | ||||
|                                                   subject: | ||||
|                                                       '${selectedIds.length} Selected App URLs from Obtainium'); | ||||
|                                                       '${selectedApps.length} Selected App URLs from Obtainium'); | ||||
|                                               Navigator.of(context).pop(); | ||||
|                                             }, | ||||
|                                             tooltip: 'Share Selected App URLs', | ||||
|                                             icon: const Icon(Icons.share), | ||||
|                                           ), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
|                                               showDialog( | ||||
|                                                   context: context, | ||||
|                                                   builder: (BuildContext ctx) { | ||||
|                                                     return GeneratedFormModal( | ||||
|                                                       title: | ||||
|                                                           'Reset Install Status for Selected Apps?', | ||||
|                                                       items: const [], | ||||
|                                                       defaultValues: const [], | ||||
|                                                       initValid: true, | ||||
|                                                       message: | ||||
|                                                           'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.', | ||||
|                                                     ); | ||||
|                                                   }).then((values) { | ||||
|                                                 if (values != null) { | ||||
|                                                   appsProvider.saveApps( | ||||
|                                                       selectedApps.map((e) { | ||||
|                                                     e.installedVersion = null; | ||||
|                                                     return e; | ||||
|                                                   }).toList()); | ||||
|                                                 } | ||||
|                                               }).whenComplete(() { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }); | ||||
|                                             }, | ||||
|                                             tooltip: 'Reset Install Status', | ||||
|                                             icon: const Icon( | ||||
|                                                 Icons.restore_page_outlined), | ||||
|                                           ), | ||||
|                                         ]), | ||||
|                                   ), | ||||
|                                 ); | ||||
|   | ||||
| @@ -8,10 +8,10 @@ import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:file_picker/file_picker.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class ImportExportPage extends StatefulWidget { | ||||
|   const ImportExportPage({super.key}); | ||||
| @@ -26,7 +26,6 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     var settingsProvider = context.read<SettingsProvider>(); | ||||
|     var appsProvider = context.read<AppsProvider>(); | ||||
|     var outlineButtonStyle = ButtonStyle( | ||||
|       shape: MaterialStateProperty.all( | ||||
| @@ -39,24 +38,6 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     Future<List<List<String>>> addApps(List<String> urls) async { | ||||
|       await settingsProvider.getInstallPermission(); | ||||
|       List<dynamic> results = await sourceProvider.getApps(urls, | ||||
|           ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList()); | ||||
|       List<App> apps = results[0]; | ||||
|       Map<String, dynamic> errorsMap = results[1]; | ||||
|       for (var app in apps) { | ||||
|         if (appsProvider.apps.containsKey(app.id)) { | ||||
|           errorsMap.addAll({app.id: 'App already added'}); | ||||
|         } else { | ||||
|           await appsProvider.saveApps([app]); | ||||
|         } | ||||
|       } | ||||
|       List<List<String>> errors = | ||||
|           errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); | ||||
|       return errors; | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -196,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                       setState(() { | ||||
|                                         importInProgress = true; | ||||
|                                       }); | ||||
|                                       addApps(urls).then((errors) { | ||||
|                                       appsProvider | ||||
|                                           .addAppsByURL(urls) | ||||
|                                           .then((errors) { | ||||
|                                         if (errors.isEmpty) { | ||||
|                                           showError( | ||||
|                                               'Imported ${urls.length} Apps', | ||||
| @@ -223,6 +206,107 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                           child: const Text( | ||||
|                             'Import from URL List', | ||||
|                           )), | ||||
|                       ...sourceProvider.sources | ||||
|                           .where((element) => element.canSearch) | ||||
|                           .map((source) => Column( | ||||
|                                   crossAxisAlignment: | ||||
|                                       CrossAxisAlignment.stretch, | ||||
|                                   children: [ | ||||
|                                     const SizedBox(height: 8), | ||||
|                                     TextButton( | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 () async { | ||||
|                                                   var values = await showDialog< | ||||
|                                                           List<String>>( | ||||
|                                                       context: context, | ||||
|                                                       builder: | ||||
|                                                           (BuildContext ctx) { | ||||
|                                                         return GeneratedFormModal( | ||||
|                                                           title: | ||||
|                                                               'Search ${source.runtimeType}', | ||||
|                                                           items: [ | ||||
|                                                             [ | ||||
|                                                               GeneratedFormItem( | ||||
|                                                                   label: | ||||
|                                                                       '${source.runtimeType} Search Query') | ||||
|                                                             ] | ||||
|                                                           ], | ||||
|                                                           defaultValues: const [], | ||||
|                                                         ); | ||||
|                                                       }); | ||||
|                                                   if (values != null && | ||||
|                                                       values[0].isNotEmpty) { | ||||
|                                                     setState(() { | ||||
|                                                       importInProgress = true; | ||||
|                                                     }); | ||||
|                                                     var urlsWithDescriptions = | ||||
|                                                         await source | ||||
|                                                             .search(values[0]); | ||||
|                                                     if (urlsWithDescriptions | ||||
|                                                         .isNotEmpty) { | ||||
|                                                       var selectedUrls = | ||||
|                                                           await showDialog< | ||||
|                                                                   List< | ||||
|                                                                       String>?>( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return UrlSelectionModal( | ||||
|                                                                   urlsWithDescriptions: | ||||
|                                                                       urlsWithDescriptions, | ||||
|                                                                   selectedByDefault: | ||||
|                                                                       false, | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                       if (selectedUrls != | ||||
|                                                               null && | ||||
|                                                           selectedUrls | ||||
|                                                               .isNotEmpty) { | ||||
|                                                         var errors = | ||||
|                                                             await appsProvider | ||||
|                                                                 .addAppsByURL( | ||||
|                                                                     selectedUrls); | ||||
|                                                         if (errors.isEmpty) { | ||||
|                                                           // ignore: use_build_context_synchronously | ||||
|                                                           showError( | ||||
|                                                               'Imported ${selectedUrls.length} Apps', | ||||
|                                                               context); | ||||
|                                                         } else { | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return ImportErrorDialog( | ||||
|                                                                     urlsLength: | ||||
|                                                                         selectedUrls | ||||
|                                                                             .length, | ||||
|                                                                     errors: | ||||
|                                                                         errors); | ||||
|                                                               }); | ||||
|                                                         } | ||||
|                                                       } | ||||
|                                                     } else { | ||||
|                                                       throw ObtainiumError( | ||||
|                                                           'No results found'); | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
|                                                     .catchError((e) { | ||||
|                                                   showError(e, context); | ||||
|                                                 }).whenComplete(() { | ||||
|                                                   setState(() { | ||||
|                                                     importInProgress = false; | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             'Search ${source.runtimeType}')) | ||||
|                                   ])) | ||||
|                           .toList(), | ||||
|                       ...sourceProvider.massUrlSources | ||||
|                           .map((source) => Column( | ||||
|                                   crossAxisAlignment: | ||||
| @@ -233,84 +317,77 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 showDialog( | ||||
|                                                     context: context, | ||||
|                                                     builder: | ||||
|                                                         (BuildContext ctx) { | ||||
|                                                       return GeneratedFormModal( | ||||
|                                                         title: | ||||
|                                                             'Import ${source.name}', | ||||
|                                                         items: source | ||||
|                                                             .requiredArgs | ||||
|                                                             .map((e) => [ | ||||
|                                                                   GeneratedFormItem( | ||||
|                                                                       label: e) | ||||
|                                                                 ]) | ||||
|                                                             .toList(), | ||||
|                                                         defaultValues: const [], | ||||
|                                                       ); | ||||
|                                                     }).then((values) { | ||||
|                                                 () async { | ||||
|                                                   var values = await showDialog( | ||||
|                                                       context: context, | ||||
|                                                       builder: | ||||
|                                                           (BuildContext ctx) { | ||||
|                                                         return GeneratedFormModal( | ||||
|                                                           title: | ||||
|                                                               'Import ${source.name}', | ||||
|                                                           items: | ||||
|                                                               source | ||||
|                                                                   .requiredArgs | ||||
|                                                                   .map( | ||||
|                                                                       (e) => [ | ||||
|                                                                             GeneratedFormItem(label: e) | ||||
|                                                                           ]) | ||||
|                                                                   .toList(), | ||||
|                                                           defaultValues: const [], | ||||
|                                                         ); | ||||
|                                                       }); | ||||
|                                                   if (values != null) { | ||||
|                                                     setState(() { | ||||
|                                                       importInProgress = true; | ||||
|                                                     }); | ||||
|                                                     source | ||||
|                                                         .getUrls(values) | ||||
|                                                         .then((urls) { | ||||
|                                                       showDialog<List<String>?>( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return UrlSelectionModal( | ||||
|                                                                     urls: urls); | ||||
|                                                               }) | ||||
|                                                           .then((selectedUrls) { | ||||
|                                                         if (selectedUrls != | ||||
|                                                             null) { | ||||
|                                                           addApps(selectedUrls) | ||||
|                                                               .then((errors) { | ||||
|                                                             if (errors | ||||
|                                                                 .isEmpty) { | ||||
|                                                               showError( | ||||
|                                                                   'Imported ${selectedUrls.length} Apps', | ||||
|                                                                   context); | ||||
|                                                             } else { | ||||
|                                                               showDialog( | ||||
|                                                                   context: | ||||
|                                                                       context, | ||||
|                                                                   builder: | ||||
|                                                                       (BuildContext | ||||
|                                                                           ctx) { | ||||
|                                                                     return ImportErrorDialog( | ||||
|                                                                         urlsLength: | ||||
|                                                                             selectedUrls | ||||
|                                                                                 .length, | ||||
|                                                                         errors: | ||||
|                                                                             errors); | ||||
|                                                                   }); | ||||
|                                                             } | ||||
|                                                           }).whenComplete(() { | ||||
|                                                             setState(() { | ||||
|                                                               importInProgress = | ||||
|                                                                   false; | ||||
|                                                     var urlsWithDescriptions = | ||||
|                                                         await source | ||||
|                                                             .getUrlsWithDescriptions( | ||||
|                                                                 values); | ||||
|                                                     var selectedUrls = | ||||
|                                                         await showDialog< | ||||
|                                                                 List<String>?>( | ||||
|                                                             context: context, | ||||
|                                                             builder: | ||||
|                                                                 (BuildContext | ||||
|                                                                     ctx) { | ||||
|                                                               return UrlSelectionModal( | ||||
|                                                                   urlsWithDescriptions: | ||||
|                                                                       urlsWithDescriptions); | ||||
|                                                             }); | ||||
|                                                           }); | ||||
|                                                         } else { | ||||
|                                                           setState(() { | ||||
|                                                             importInProgress = | ||||
|                                                                 false; | ||||
|                                                           }); | ||||
|                                                         } | ||||
|                                                       }); | ||||
|                                                     }).catchError((e) { | ||||
|                                                       setState(() { | ||||
|                                                         importInProgress = | ||||
|                                                             false; | ||||
|                                                       }); | ||||
|                                                       showError(e, context); | ||||
|                                                     }); | ||||
|                                                     if (selectedUrls != null) { | ||||
|                                                       var errors = | ||||
|                                                           await appsProvider | ||||
|                                                               .addAppsByURL( | ||||
|                                                                   selectedUrls); | ||||
|                                                       if (errors.isEmpty) { | ||||
|                                                         // ignore: use_build_context_synchronously | ||||
|                                                         showError( | ||||
|                                                             'Imported ${selectedUrls.length} Apps', | ||||
|                                                             context); | ||||
|                                                       } else { | ||||
|                                                         showDialog( | ||||
|                                                             context: context, | ||||
|                                                             builder: | ||||
|                                                                 (BuildContext | ||||
|                                                                     ctx) { | ||||
|                                                               return ImportErrorDialog( | ||||
|                                                                   urlsLength: | ||||
|                                                                       selectedUrls | ||||
|                                                                           .length, | ||||
|                                                                   errors: | ||||
|                                                                       errors); | ||||
|                                                             }); | ||||
|                                                       } | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
|                                                     .catchError((e) { | ||||
|                                                   showError(e, context); | ||||
|                                                 }).whenComplete(() { | ||||
|                                                   setState(() { | ||||
|                                                     importInProgress = false; | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                               }, | ||||
|                                         child: Text('Import ${source.name}')) | ||||
| @@ -390,21 +467,37 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class UrlSelectionModal extends StatefulWidget { | ||||
|   UrlSelectionModal({super.key, required this.urls}); | ||||
|   UrlSelectionModal( | ||||
|       {super.key, | ||||
|       required this.urlsWithDescriptions, | ||||
|       this.selectedByDefault = true, | ||||
|       this.onlyOneSelectionAllowed = false}); | ||||
|  | ||||
|   List<String> urls; | ||||
|   Map<String, String> urlsWithDescriptions; | ||||
|   bool selectedByDefault; | ||||
|   bool onlyOneSelectionAllowed; | ||||
|  | ||||
|   @override | ||||
|   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); | ||||
| } | ||||
|  | ||||
| class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|   Map<String, bool> urlSelections = {}; | ||||
|   Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {}; | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     for (var url in widget.urls) { | ||||
|       urlSelections.putIfAbsent(url, () => true); | ||||
|     for (var url in widget.urlsWithDescriptions.entries) { | ||||
|       urlWithDescriptionSelections.putIfAbsent(url, | ||||
|           () => widget.selectedByDefault && !widget.onlyOneSelectionAllowed); | ||||
|     } | ||||
|     if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) { | ||||
|       selectOnlyOne(widget.urlsWithDescriptions.entries.first.key); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   selectOnlyOne(String url) { | ||||
|     for (var uwd in urlWithDescriptionSelections.keys) { | ||||
|       urlWithDescriptionSelections[uwd] = uwd.key == url; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -412,23 +505,56 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Select URLs to Import'), | ||||
|       title: | ||||
|           Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'), | ||||
|       content: Column(children: [ | ||||
|         ...urlSelections.keys.map((url) { | ||||
|         ...urlWithDescriptionSelections.keys.map((urlWithD) { | ||||
|           return Row(children: [ | ||||
|             Checkbox( | ||||
|                 value: urlSelections[url], | ||||
|                 value: urlWithDescriptionSelections[urlWithD], | ||||
|                 onChanged: (value) { | ||||
|                   setState(() { | ||||
|                     urlSelections[url] = value ?? false; | ||||
|                     value ??= false; | ||||
|                     if (value! && widget.onlyOneSelectionAllowed) { | ||||
|                       selectOnlyOne(urlWithD.key); | ||||
|                     } else { | ||||
|                       urlWithDescriptionSelections[urlWithD] = value!; | ||||
|                     } | ||||
|                   }); | ||||
|                 }), | ||||
|             const SizedBox( | ||||
|               width: 8, | ||||
|             ), | ||||
|             Expanded( | ||||
|                 child: Text( | ||||
|               Uri.parse(url).path.substring(1), | ||||
|                 child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               children: [ | ||||
|                 const SizedBox( | ||||
|                   height: 8, | ||||
|                 ), | ||||
|                 GestureDetector( | ||||
|                     onTap: () { | ||||
|                       launchUrlString(urlWithD.key, | ||||
|                           mode: LaunchMode.externalApplication); | ||||
|                     }, | ||||
|                     child: Text( | ||||
|                       Uri.parse(urlWithD.key).path.substring(1), | ||||
|                       style: | ||||
|                           const TextStyle(decoration: TextDecoration.underline), | ||||
|                       textAlign: TextAlign.start, | ||||
|                     )), | ||||
|                 Text( | ||||
|                   urlWithD.value.length > 128 | ||||
|                       ? '${urlWithD.value.substring(0, 128)}...' | ||||
|                       : urlWithD.value, | ||||
|                   style: const TextStyle( | ||||
|                       fontStyle: FontStyle.italic, fontSize: 12), | ||||
|                 ), | ||||
|                 const SizedBox( | ||||
|                   height: 8, | ||||
|                 ) | ||||
|               ], | ||||
|             )) | ||||
|           ]); | ||||
|         }) | ||||
| @@ -440,13 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|             }, | ||||
|             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')) | ||||
|             onPressed: | ||||
|                 urlWithDescriptionSelections.values.where((b) => b).isEmpty | ||||
|                     ? null | ||||
|                     : () { | ||||
|                         Navigator.of(context).pop(urlWithDescriptionSelections | ||||
|                             .entries | ||||
|                             .where((entry) => entry.value) | ||||
|                             .map((e) => e.key.key) | ||||
|                             .toList()); | ||||
|                       }, | ||||
|             child: Text(widget.onlyOneSelectionAllowed | ||||
|                 ? 'Pick' | ||||
|                 : 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class SettingsPage extends StatefulWidget { | ||||
| @@ -135,18 +138,21 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|         }); | ||||
|  | ||||
|     var sourceSpecificFields = sourceProvider.sources.map((e) { | ||||
|       if (e.moreSourceSettingsFormItems.isNotEmpty) { | ||||
|       if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) { | ||||
|         return GeneratedForm( | ||||
|             items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), | ||||
|             onValueChanges: (values, valid) { | ||||
|             items: e.additionalSourceSpecificSettingFormItems | ||||
|                 .map((e) => [e]) | ||||
|                 .toList(), | ||||
|             onValueChanges: (values, valid, isBuilding) { | ||||
|               if (valid) { | ||||
|                 for (var i = 0; i < values.length; i++) { | ||||
|                   settingsProvider.setSettingString( | ||||
|                       e.moreSourceSettingsFormItems[i].id, values[i]); | ||||
|                       e.additionalSourceSpecificSettingFormItems[i].id, | ||||
|                       values[i]); | ||||
|                 } | ||||
|               } | ||||
|             }, | ||||
|             defaultValues: e.moreSourceSettingsFormItems.map((e) { | ||||
|             defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) { | ||||
|               return settingsProvider.getSettingString(e.id) ?? ''; | ||||
|             }).toList()); | ||||
|       } else { | ||||
| @@ -238,23 +244,39 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|           SliverToBoxAdapter( | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 height16, | ||||
|                 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 Divider( | ||||
|                   height: 32, | ||||
|                 ), | ||||
|                 Row( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||
|                   children: [ | ||||
|                     TextButton.icon( | ||||
|                       onPressed: () { | ||||
|                         launchUrlString(settingsProvider.sourceUrl, | ||||
|                             mode: LaunchMode.externalApplication); | ||||
|                       }, | ||||
|                       icon: const Icon(Icons.code), | ||||
|                       label: const Text( | ||||
|                         'App Source', | ||||
|                       ), | ||||
|                     ), | ||||
|                     TextButton.icon( | ||||
|                         onPressed: () { | ||||
|                           context.read<LogsProvider>().get().then((logs) { | ||||
|                             if (logs.isEmpty) { | ||||
|                               showError(ObtainiumError('No Logs'), context); | ||||
|                             } else { | ||||
|                               showDialog( | ||||
|                                   context: context, | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return const LogsDialog(); | ||||
|                                   }); | ||||
|                             } | ||||
|                           }); | ||||
|                         }, | ||||
|                         icon: const Icon(Icons.bug_report_outlined), | ||||
|                         label: const Text('App Logs')), | ||||
|                   ], | ||||
|                 ), | ||||
|                 height16, | ||||
|               ], | ||||
| @@ -263,3 +285,71 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|         ])); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class LogsDialog extends StatefulWidget { | ||||
|   const LogsDialog({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<LogsDialog> createState() => _LogsDialogState(); | ||||
| } | ||||
|  | ||||
| class _LogsDialogState extends State<LogsDialog> { | ||||
|   String? logString; | ||||
|   List<int> days = [7, 5, 4, 3, 2, 1]; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var logsProvider = context.read<LogsProvider>(); | ||||
|     void filterLogs(int days) { | ||||
|       logsProvider | ||||
|           .get(after: DateTime.now().subtract(Duration(days: days))) | ||||
|           .then((value) { | ||||
|         setState(() { | ||||
|           String l = value.map((e) => e.toString()).join('\n\n'); | ||||
|           logString = l.isNotEmpty ? l : 'No Logs'; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (logString == null) { | ||||
|       filterLogs(days.first); | ||||
|     } | ||||
|  | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: const Text('Obtainium App Logs'), | ||||
|       content: Column( | ||||
|         children: [ | ||||
|           DropdownButtonFormField( | ||||
|               value: days.first, | ||||
|               items: days | ||||
|                   .map((e) => DropdownMenuItem( | ||||
|                         value: e, | ||||
|                         child: Text('$e Day${e == 1 ? '' : 's'}'), | ||||
|                       )) | ||||
|                   .toList(), | ||||
|               onChanged: (d) { | ||||
|                 filterLogs(d ?? 7); | ||||
|               }), | ||||
|           const SizedBox( | ||||
|             height: 32, | ||||
|           ), | ||||
|           Text(logString ?? '') | ||||
|         ], | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(); | ||||
|             }, | ||||
|             child: const Text('Close')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               Share.share(logString ?? '', subject: 'Obtainium App Logs'); | ||||
|               Navigator.of(context).pop(); | ||||
|             }, | ||||
|             child: const Text('Share')) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -12,9 +12,10 @@ import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||
| import 'package:installed_apps/app_info.dart'; | ||||
| import 'package:installed_apps/installed_apps.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:package_archive_info/package_archive_info.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| @@ -42,6 +43,7 @@ class AppsProvider with ChangeNotifier { | ||||
|   bool loadingApps = false; | ||||
|   bool gettingUpdates = false; | ||||
|   bool forBGTask = false; | ||||
|   LogsProvider logs = LogsProvider(); | ||||
|  | ||||
|   // Variables to keep track of the app foreground status (installs can't run in the background) | ||||
|   bool isForeground = true; | ||||
| @@ -63,7 +65,9 @@ class AppsProvider with ChangeNotifier { | ||||
|         // Delete existing APKs | ||||
|         (await getExternalStorageDirectory()) | ||||
|             ?.listSync() | ||||
|             .where((element) => element.path.endsWith('.apk')) | ||||
|             .where((element) => | ||||
|                 element.path.endsWith('.apk') || | ||||
|                 element.path.endsWith('.apk.part')) | ||||
|             .forEach((apk) { | ||||
|           apk.delete(); | ||||
|         }); | ||||
| @@ -71,38 +75,39 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   downloadFile(String url, String fileName, Function? onProgress) async { | ||||
|   downloadFile(String url, String fileName, Function? onProgress, | ||||
|       {bool useExisting = true}) async { | ||||
|     var destDir = (await getExternalStorageDirectory())!.path; | ||||
|     StreamedResponse response = | ||||
|         await Client().send(Request('GET', Uri.parse(url))); | ||||
|     File downloadedFile = File('$destDir/$fileName'); | ||||
|  | ||||
|     if (downloadedFile.existsSync()) { | ||||
|       downloadedFile.deleteSync(); | ||||
|     } | ||||
|     var length = response.contentLength; | ||||
|     var received = 0; | ||||
|     double? progress; | ||||
|     var sink = downloadedFile.openWrite(); | ||||
|  | ||||
|     await response.stream.map((s) { | ||||
|       received += s.length; | ||||
|       progress = (length != null ? received / length * 100 : 30); | ||||
|     if (!(downloadedFile.existsSync() && useExisting)) { | ||||
|       File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||
|       if (tempDownloadedFile.existsSync()) { | ||||
|         tempDownloadedFile.deleteSync(); | ||||
|       } | ||||
|       var length = response.contentLength; | ||||
|       var received = 0; | ||||
|       double? progress; | ||||
|       var sink = tempDownloadedFile.openWrite(); | ||||
|       await response.stream.map((s) { | ||||
|         received += s.length; | ||||
|         progress = (length != null ? received / length * 100 : 30); | ||||
|         if (onProgress != null) { | ||||
|           onProgress(progress); | ||||
|         } | ||||
|         return s; | ||||
|       }).pipe(sink); | ||||
|       await sink.close(); | ||||
|       progress = null; | ||||
|       if (onProgress != null) { | ||||
|         onProgress(progress); | ||||
|       } | ||||
|       return s; | ||||
|     }).pipe(sink); | ||||
|  | ||||
|     await sink.close(); | ||||
|     progress = null; | ||||
|     if (onProgress != null) { | ||||
|       onProgress(progress); | ||||
|     } | ||||
|  | ||||
|     if (response.statusCode != 200) { | ||||
|       downloadedFile.deleteSync(); | ||||
|       throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|       if (response.statusCode != 200) { | ||||
|         tempDownloadedFile.deleteSync(); | ||||
|         throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|       } | ||||
|       tempDownloadedFile.renameSync(downloadedFile.path); | ||||
|     } | ||||
|     return downloadedFile; | ||||
|   } | ||||
| @@ -182,6 +187,15 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<bool> canDowngradeApps() async { | ||||
|     try { | ||||
|       await InstalledApps.getAppInfo('com.berdik.letmedowngrade'); | ||||
|       return true; | ||||
|     } catch (e) { | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // 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 | ||||
| @@ -195,7 +209,8 @@ class AppsProvider with ChangeNotifier { | ||||
|       // OK | ||||
|     } | ||||
|     if (appInfo != null && | ||||
|         int.parse(newInfo.buildNumber) < appInfo.versionCode!) { | ||||
|         int.parse(newInfo.buildNumber) < appInfo.versionCode! && | ||||
|         !(await canDowngradeApps())) { | ||||
|       throw DowngradeError(); | ||||
|     } | ||||
|     if (appInfo == null || | ||||
| @@ -251,6 +266,7 @@ class AppsProvider with ChangeNotifier { | ||||
|   Future<List<String>> downloadAndInstallLatestApps( | ||||
|       List<String> appIds, BuildContext? context) async { | ||||
|     List<String> appsToInstall = []; | ||||
|     List<String> trackOnlyAppsToUpdate = []; | ||||
|     // For all specified Apps, filter out those for which: | ||||
|     // 1. A URL cannot be picked | ||||
|     // 2. That cannot be installed silently (IF no buildContext was given for interactive install) | ||||
| @@ -258,7 +274,10 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (apps[id] == null) { | ||||
|         throw ObtainiumError('App not found'); | ||||
|       } | ||||
|       String? apkUrl = await confirmApkUrl(apps[id]!.app, context); | ||||
|       String? apkUrl; | ||||
|       if (!apps[id]!.app.trackOnly) { | ||||
|         apkUrl = await confirmApkUrl(apps[id]!.app, context); | ||||
|       } | ||||
|       if (apkUrl != null) { | ||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||
| @@ -269,7 +288,16 @@ class AppsProvider with ChangeNotifier { | ||||
|           appsToInstall.add(id); | ||||
|         } | ||||
|       } | ||||
|       if (apps[id]!.app.trackOnly) { | ||||
|         trackOnlyAppsToUpdate.add(id); | ||||
|       } | ||||
|     } | ||||
|     // Mark all specified track-only apps as latest | ||||
|     saveApps(trackOnlyAppsToUpdate.map((e) { | ||||
|       var a = apps[e]!.app; | ||||
|       a.installedVersion = a.latestVersion; | ||||
|       return a; | ||||
|     }).toList()); | ||||
|     // Download APKs for all Apps to be installed | ||||
|     MultiAppMultiError errors = MultiAppMultiError(); | ||||
|     List<DownloadedApk?> downloadedFiles = | ||||
| @@ -300,10 +328,10 @@ class AppsProvider with ChangeNotifier { | ||||
|  | ||||
|     // If Obtainium is being installed, it should be the last one | ||||
|     List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { | ||||
|       String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; | ||||
|       DownloadedApk? temp; | ||||
|       items.removeWhere((element) { | ||||
|         bool res = element.appId == obtainiumId; | ||||
|         bool res = | ||||
|             element.appId == obtainiumId || element.appId == obtainiumTempId; | ||||
|         if (res) { | ||||
|           temp = element; | ||||
|         } | ||||
| @@ -376,7 +404,9 @@ class AppsProvider with ChangeNotifier { | ||||
|       return null; // Can't correct in the background isolate | ||||
|     } | ||||
|     var modded = false; | ||||
|     if (installedInfo == null && app.installedVersion != null) { | ||||
|     if (installedInfo == null && | ||||
|         app.installedVersion != null && | ||||
|         !app.trackOnly) { | ||||
|       app.installedVersion = null; | ||||
|       modded = true; | ||||
|     } | ||||
| @@ -412,22 +442,27 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|     loadingApps = true; | ||||
|     notifyListeners(); | ||||
|     List<FileSystemEntity> appFiles = (await getAppsDir()) | ||||
|     List<App> newApps = (await getAppsDir()) | ||||
|         .listSync() | ||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||
|         .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) | ||||
|         .toList(); | ||||
|     apps.clear(); | ||||
|     var idsToDelete = apps.values | ||||
|         .map((e) => e.app.id) | ||||
|         .toSet() | ||||
|         .difference(newApps.map((e) => e.id).toSet()); | ||||
|     for (var id in idsToDelete) { | ||||
|       apps.remove(id); | ||||
|     } | ||||
|     var sp = SourceProvider(); | ||||
|     List<List<String>> errors = []; | ||||
|     for (int i = 0; i < appFiles.length; i++) { | ||||
|       App app = | ||||
|           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); | ||||
|       var info = await getInstalledInfo(app.id); | ||||
|     for (int i = 0; i < newApps.length; i++) { | ||||
|       var info = await getInstalledInfo(newApps[i].id); | ||||
|       try { | ||||
|         sp.getSource(app.url); | ||||
|         apps.putIfAbsent(app.id, () => AppInMemory(app, null, info)); | ||||
|         sp.getSource(newApps[i].url); | ||||
|         apps[newApps[i].id] = AppInMemory(newApps[i], null, info); | ||||
|       } catch (e) { | ||||
|         errors.add([app.id, app.name, e.toString()]); | ||||
|         errors.add([newApps[i].id, newApps[i].name, e.toString()]); | ||||
|       } | ||||
|     } | ||||
|     if (errors.isNotEmpty) { | ||||
| @@ -490,8 +525,10 @@ class AppsProvider with ChangeNotifier { | ||||
|         currentApp.url, | ||||
|         currentApp.additionalData, | ||||
|         name: currentApp.name, | ||||
|         id: currentApp.id); | ||||
|     newApp.installedVersion = currentApp.installedVersion; | ||||
|         id: currentApp.id, | ||||
|         pinned: currentApp.pinned, | ||||
|         trackOnly: currentApp.trackOnly, | ||||
|         installedVersion: currentApp.installedVersion); | ||||
|     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|     } | ||||
| @@ -598,6 +635,23 @@ class AppsProvider with ChangeNotifier { | ||||
|     foregroundSubscription?.cancel(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   Future<List<List<String>>> addAppsByURL(List<String> urls) async { | ||||
|     List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls, | ||||
|         ignoreUrls: apps.values.map((e) => e.app.url).toList()); | ||||
|     List<App> pps = results[0]; | ||||
|     Map<String, dynamic> errorsMap = results[1]; | ||||
|     for (var app in pps) { | ||||
|       if (apps.containsKey(app.id)) { | ||||
|         errorsMap.addAll({app.id: 'App already added'}); | ||||
|       } else { | ||||
|         await saveApps([app]); | ||||
|       } | ||||
|     } | ||||
|     List<List<String>> errors = | ||||
|         errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); | ||||
|     return errors; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class APKPicker extends StatefulWidget { | ||||
|   | ||||
							
								
								
									
										109
									
								
								lib/providers/logs_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,109 @@ | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:sqflite/sqflite.dart'; | ||||
|  | ||||
| const String logTable = 'logs'; | ||||
| const String idColumn = '_id'; | ||||
| const String levelColumn = 'level'; | ||||
| const String messageColumn = 'message'; | ||||
| const String timestampColumn = 'timestamp'; | ||||
| const String dbPath = 'logs.db'; | ||||
|  | ||||
| enum LogLevels { debug, info, warning, error } | ||||
|  | ||||
| class Log { | ||||
|   int? id; | ||||
|   late LogLevels level; | ||||
|   late String message; | ||||
|   DateTime timestamp = DateTime.now(); | ||||
|  | ||||
|   Map<String, Object?> toMap() { | ||||
|     var map = <String, Object?>{ | ||||
|       idColumn: id, | ||||
|       levelColumn: level.index, | ||||
|       messageColumn: message, | ||||
|       timestampColumn: timestamp.millisecondsSinceEpoch | ||||
|     }; | ||||
|     return map; | ||||
|   } | ||||
|  | ||||
|   Log(this.message, this.level); | ||||
|  | ||||
|   Log.fromMap(Map<String, Object?> map) { | ||||
|     id = map[idColumn] as int; | ||||
|     level = LogLevels.values.elementAt(map[levelColumn] as int); | ||||
|     message = map[messageColumn] as String; | ||||
|     timestamp = | ||||
|         DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return '${timestamp.toString()}: ${level.name}: $message'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class LogsProvider { | ||||
|   LogsProvider({bool runDefaultClear = true}) { | ||||
|     clear(before: DateTime.now().subtract(const Duration(days: 7))); | ||||
|   } | ||||
|  | ||||
|   Database? db; | ||||
|  | ||||
|   Future<Database> getDB() async { | ||||
|     db ??= await openDatabase(dbPath, version: 1, | ||||
|         onCreate: (Database db, int version) async { | ||||
|       await db.execute(''' | ||||
| create table if not exists $logTable (  | ||||
|   $idColumn integer primary key autoincrement,  | ||||
|   $levelColumn integer not null, | ||||
|   $messageColumn text not null, | ||||
|   $timestampColumn integer not null) | ||||
| '''); | ||||
|     }); | ||||
|     return db!; | ||||
|   } | ||||
|  | ||||
|   Future<Log> add(String message, {LogLevels level = LogLevels.info}) async { | ||||
|     Log l = Log(message, level); | ||||
|     l.id = await (await getDB()).insert(logTable, l.toMap()); | ||||
|     if (kDebugMode) { | ||||
|       print(l); | ||||
|     } | ||||
|     return l; | ||||
|   } | ||||
|  | ||||
|   Future<List<Log>> get({DateTime? before, DateTime? after}) async { | ||||
|     var where = getWhereDates(before: before, after: after); | ||||
|     return (await (await getDB()) | ||||
|             .query(logTable, where: where.key, whereArgs: where.value)) | ||||
|         .map((e) => Log.fromMap(e)) | ||||
|         .toList(); | ||||
|   } | ||||
|  | ||||
|   Future<int> clear({DateTime? before, DateTime? after}) async { | ||||
|     var where = getWhereDates(before: before, after: after); | ||||
|     var res = await (await getDB()) | ||||
|         .delete(logTable, where: where.key, whereArgs: where.value); | ||||
|     if (res > 0) { | ||||
|       add('Cleared $res logs (before = $before, after = $after)'); | ||||
|     } | ||||
|     return res; | ||||
|   } | ||||
| } | ||||
|  | ||||
| MapEntry<String?, List<int>?> getWhereDates( | ||||
|     {DateTime? before, DateTime? after}) { | ||||
|   List<String> where = []; | ||||
|   List<int> whereArgs = []; | ||||
|   if (before != null) { | ||||
|     where.add('$timestampColumn < ?'); | ||||
|     whereArgs.add(before.millisecondsSinceEpoch); | ||||
|   } | ||||
|   if (after != null) { | ||||
|     where.add('$timestampColumn > ?'); | ||||
|     whereArgs.add(after.millisecondsSinceEpoch); | ||||
|   } | ||||
|   return whereArgs.isEmpty | ||||
|       ? const MapEntry(null, null) | ||||
|       : MapEntry(where.join(' and '), whereArgs); | ||||
| } | ||||
| @@ -2,9 +2,13 @@ | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
|  | ||||
| String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}'; | ||||
| String obtainiumId = 'dev.imranr.obtainium'; | ||||
|  | ||||
| enum ThemeSettings { system, light, dark } | ||||
|  | ||||
| enum ColourSettings { basic, materialYou } | ||||
|   | ||||
| @@ -4,6 +4,8 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/apkmirror.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/app_sources/gitlab.dart'; | ||||
| @@ -40,6 +42,8 @@ class App { | ||||
|   late int preferredApkIndex; | ||||
|   late List<String> additionalData; | ||||
|   late DateTime? lastUpdateCheck; | ||||
|   bool pinned = false; | ||||
|   bool trackOnly = false; | ||||
|   App( | ||||
|       this.id, | ||||
|       this.url, | ||||
| @@ -50,11 +54,13 @@ class App { | ||||
|       this.apkUrls, | ||||
|       this.preferredApkIndex, | ||||
|       this.additionalData, | ||||
|       this.lastUpdateCheck); | ||||
|       this.lastUpdateCheck, | ||||
|       this.pinned, | ||||
|       this.trackOnly); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}'; | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned'; | ||||
|   } | ||||
|  | ||||
|   factory App.fromJson(Map<String, dynamic> json) => App( | ||||
| @@ -71,11 +77,15 @@ class App { | ||||
|           : List<String>.from(jsonDecode(json['apkUrls'])), | ||||
|       json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, | ||||
|       json['additionalData'] == null | ||||
|           ? SourceProvider().getSource(json['url']).additionalDataDefaults | ||||
|           ? SourceProvider() | ||||
|               .getSource(json['url']) | ||||
|               .additionalSourceAppSpecificDefaults | ||||
|           : List<String>.from(jsonDecode(json['additionalData'])), | ||||
|       json['lastUpdateCheck'] == null | ||||
|           ? null | ||||
|           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); | ||||
|           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), | ||||
|       json['pinned'] ?? false, | ||||
|       json['trackOnly'] ?? false); | ||||
|  | ||||
|   Map<String, dynamic> toJson() => { | ||||
|         'id': id, | ||||
| @@ -87,7 +97,9 @@ class App { | ||||
|         'apkUrls': jsonEncode(apkUrls), | ||||
|         'preferredApkIndex': preferredApkIndex, | ||||
|         'additionalData': jsonEncode(additionalData), | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||
|         'pinned': pinned, | ||||
|         'trackOnly': trackOnly | ||||
|       }; | ||||
| } | ||||
|  | ||||
| @@ -128,23 +140,66 @@ List<String> getLinksFromParsedHTML( | ||||
|         .map((e) => '$prependToLinks${e.attributes['href']!}') | ||||
|         .toList(); | ||||
|  | ||||
| abstract class AppSource { | ||||
| class AppSource { | ||||
|   late String host; | ||||
|   String standardizeURL(String url); | ||||
|   bool enforceTrackOnly = false; | ||||
|   String standardizeURL(String url) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData); | ||||
|   AppNames getAppNames(String standardUrl); | ||||
|   late List<List<GeneratedFormItem>> additionalDataFormItems; | ||||
|   late List<String> additionalDataDefaults; | ||||
|   late List<GeneratedFormItem> moreSourceSettingsFormItems; | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl); | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl); | ||||
|       String standardUrl, List<String> additionalData, | ||||
|       {bool trackOnly = false}) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   AppNames getAppNames(String standardUrl) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   // Different Sources may need different kinds of additional data for Apps | ||||
|   List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = []; | ||||
|   List<String> additionalSourceAppSpecificDefaults = []; | ||||
|  | ||||
|   // Some additional data may be needed for Apps regardless of Source | ||||
|   final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [ | ||||
|     GeneratedFormItem( | ||||
|         label: 'Track-Only', | ||||
|         type: FormItemType.bool, | ||||
|         key: 'trackOnlyFormItemKey') | ||||
|   ]; | ||||
|   final List<String> additionalAppSpecificSourceAgnosticDefaults = ['']; | ||||
|  | ||||
|   // Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider | ||||
|   List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = []; | ||||
|  | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { | ||||
|     return apkUrl; | ||||
|   } | ||||
|  | ||||
|   bool canSearch = false; | ||||
|   Future<Map<String, String>> search(String query) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
|  | ||||
|   String? tryInferringAppId(String standardUrl) { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| ObtainiumError getObtainiumHttpError(Response res) { | ||||
|   return ObtainiumError( | ||||
|       res.reasonPhrase ?? 'Error ${res.statusCode.toString()}'); | ||||
| } | ||||
|  | ||||
| abstract class MassAppUrlSource { | ||||
|   late String name; | ||||
|   late List<String> requiredArgs; | ||||
|   Future<List<String>> getUrls(List<String> args); | ||||
|   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args); | ||||
| } | ||||
|  | ||||
| class SourceProvider { | ||||
| @@ -156,7 +211,8 @@ class SourceProvider { | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     SourceForge() | ||||
|     SourceForge(), | ||||
|     APKMirror() | ||||
|   ]; | ||||
|  | ||||
|   // Add more mass url source classes here so they are available via the service | ||||
| @@ -178,7 +234,7 @@ class SourceProvider { | ||||
|   } | ||||
|  | ||||
|   bool ifSourceAppsRequireAdditionalData(AppSource source) { | ||||
|     for (var row in source.additionalDataFormItems) { | ||||
|     for (var row in source.additionalSourceAppSpecificFormItems) { | ||||
|       for (var element in row) { | ||||
|         if (element.required) { | ||||
|           return true; | ||||
| @@ -201,45 +257,56 @@ class SourceProvider { | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|     return getSourceHosts().contains(parts.last); | ||||
|     return sources.map((e) => e.host).contains(parts.last); | ||||
|   } | ||||
|  | ||||
|   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||
|       {String name = '', String? id}) async { | ||||
|       {String name = '', | ||||
|       String? id, | ||||
|       bool pinned = false, | ||||
|       bool trackOnly = false, | ||||
|       String? installedVersion}) async { | ||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||
|     AppNames names = source.getAppNames(standardUrl); | ||||
|     APKDetails apk = | ||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||
|     APKDetails apk = await source | ||||
|         .getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly); | ||||
|     if (apk.apkUrls.isEmpty && !trackOnly) { | ||||
|       throw NoAPKError(); | ||||
|     } | ||||
|     String apkVersion = apk.version.replaceAll('/', '-'); | ||||
|     return App( | ||||
|         id ?? generateTempID(names, source), | ||||
|         id ?? | ||||
|             source.tryInferringAppId(standardUrl) ?? | ||||
|             generateTempID(names, source), | ||||
|         standardUrl, | ||||
|         names.author[0].toUpperCase() + names.author.substring(1), | ||||
|         name.trim().isNotEmpty | ||||
|             ? name | ||||
|             : names.name[0].toUpperCase() + names.name.substring(1), | ||||
|         null, | ||||
|         apk.version.replaceAll('/', '-'), | ||||
|         installedVersion, | ||||
|         apkVersion, | ||||
|         apk.apkUrls, | ||||
|         apk.apkUrls.length - 1, | ||||
|         additionalData, | ||||
|         DateTime.now()); | ||||
|         DateTime.now(), | ||||
|         pinned, | ||||
|         trackOnly); | ||||
|   } | ||||
|  | ||||
|   // Returns errors in [results, errors] instead of throwing them | ||||
|   Future<List<dynamic>> getApps(List<String> urls, | ||||
|   Future<List<dynamic>> getAppsByURLNaive(List<String> urls, | ||||
|       {List<String> ignoreUrls = const []}) async { | ||||
|     List<App> apps = []; | ||||
|     Map<String, dynamic> errors = {}; | ||||
|     for (var url in urls.where((element) => !ignoreUrls.contains(element))) { | ||||
|       try { | ||||
|         var source = getSource(url); | ||||
|         apps.add(await getApp(source, url, source.additionalDataDefaults)); | ||||
|         apps.add(await getApp( | ||||
|             source, url, source.additionalSourceAppSpecificDefaults)); | ||||
|       } catch (e) { | ||||
|         errors.addAll(<String, dynamic>{url: e}); | ||||
|       } | ||||
|     } | ||||
|     return [apps, errors]; | ||||
|   } | ||||
|  | ||||
|   List<String> getSourceHosts() => sources.map((e) => e.host).toList(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										55
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -21,7 +21,7 @@ packages: | ||||
|       name: archive | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.3.2" | ||||
|     version: "3.3.5" | ||||
|   args: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -78,6 +78,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.16.0" | ||||
|   convert: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: convert | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.1.1" | ||||
|   cross_file: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -173,14 +180,14 @@ packages: | ||||
|       name: flutter_fgbg | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|     version: "0.2.1" | ||||
|   flutter_launcher_icons: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
|       name: flutter_launcher_icons | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.10.0" | ||||
|     version: "0.11.0" | ||||
|   flutter_lints: | ||||
|     dependency: "direct dev" | ||||
|     description: | ||||
| @@ -194,7 +201,7 @@ packages: | ||||
|       name: flutter_local_notifications | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "12.0.3" | ||||
|     version: "12.0.4" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -365,7 +372,7 @@ packages: | ||||
|       name: path_provider_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.21" | ||||
|     version: "2.0.22" | ||||
|   path_provider_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -457,6 +464,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.3" | ||||
|   pointycastle: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: pointycastle | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.6.2" | ||||
|   process: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -477,7 +491,7 @@ packages: | ||||
|       name: share_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.2.0" | ||||
|     version: "6.3.0" | ||||
|   share_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -553,6 +567,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.0" | ||||
|   sqflite: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: sqflite | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.2.0+3" | ||||
|   sqflite_common: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: sqflite_common | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.4.0+2" | ||||
|   stack_trace: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -574,6 +602,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   synchronized: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: synchronized | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0+3" | ||||
|   term_glyph: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -608,14 +643,14 @@ packages: | ||||
|       name: url_launcher | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.1.6" | ||||
|     version: "6.1.7" | ||||
|   url_launcher_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.21" | ||||
|     version: "6.0.22" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -664,7 +699,7 @@ packages: | ||||
|       name: uuid | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.6" | ||||
|     version: "3.0.7" | ||||
|   vector_math: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -706,7 +741,7 @@ packages: | ||||
|       name: win32 | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|     version: "3.1.2" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.6.11+55 # When changing this, update the tag in main() accordingly | ||||
| version: 0.8.1+64 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
| @@ -56,12 +56,13 @@ dependencies: | ||||
|   installed_apps: ^1.3.1 | ||||
|   package_archive_info: ^0.1.0 | ||||
|   android_alarm_manager_plus: ^2.1.0 | ||||
|   sqflite: ^2.2.0+3 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|     sdk: flutter | ||||
|   flutter_launcher_icons: ^0.10.0 | ||||
|   flutter_launcher_icons: ^0.11.0 | ||||
|  | ||||
|   # The "flutter_lints" package below contains a set of recommended lints to | ||||
|   # encourage good coding practices. The lint set provided by the package is | ||||
|   | ||||