Compare commits
	
		
			57 Commits
		
	
	
		
			v0.6.0-bet
			...
			v0.7.5-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 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 | ||
|  | e158c23cca | ||
|  | 208f125e12 | ||
|  | b7ccf3fa49 | ||
|  | c746e89052 | ||
|  | ee758e8470 | ||
|  | 68d903e092 | ||
|  | c47b752344 | ||
|  | 62a05996cf | ||
|  | 1cda941fbe | ||
|  | 49cb908d04 | ||
|  | 139f44d31d | ||
|  | ed955ac6a2 | ||
|  | f3ead6caf1 | ||
|  | 97ab723d04 | ||
|  | ed4a26d348 | ||
|  | bd5f21984e | ||
|  | 5037d77b14 | ||
|  | c9711c7734 | ||
|  | 76e98feeb7 | ||
|  | 03da23f77a | ||
|  | 9b99e2b302 | ||
|  | e746ca890a | ||
|  | 9c00a7da14 | ||
|  | 4df0dd64ad | ||
|  | 7cf7ffe0de | ||
|  | b1953435af | ||
|  | fc7d7d11d6 | ||
|  | 9ef26b3a4a | ||
|  | 27ee6b9e88 | 
| @@ -30,7 +30,25 @@ | |||||||
|         <meta-data |         <meta-data | ||||||
|             android:name="flutterEmbedding" |             android:name="flutterEmbedding" | ||||||
|             android:value="2" /> |             android:value="2" /> | ||||||
|  |         <service | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService" | ||||||
|  |             android:permission="android.permission.BIND_JOB_SERVICE" | ||||||
|  |             android:exported="false"/> | ||||||
|  |         <receiver | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver" | ||||||
|  |             android:exported="false"/> | ||||||
|  |         <receiver | ||||||
|  |             android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver" | ||||||
|  |             android:enabled="false" | ||||||
|  |             android:exported="false"> | ||||||
|  |             <intent-filter> | ||||||
|  |                 <action android:name="android.intent.action.BOOT_COMPLETED" /> | ||||||
|  |             </intent-filter> | ||||||
|  |         </receiver> | ||||||
|     </application> |     </application> | ||||||
|     <uses-permission android:name="android.permission.INTERNET" /> |     <uses-permission android:name="android.permission.INTERNET" /> | ||||||
|     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> |     <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | ||||||
|  |     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> | ||||||
|  |     <uses-permission android:name="android.permission.WAKE_LOCK"/> | ||||||
|  |     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> | ||||||
| </manifest> | </manifest> | ||||||
| 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 | 
| @@ -1,112 +0,0 @@ | |||||||
| import 'package:html/parser.dart'; |  | ||||||
| import 'package:http/http.dart'; |  | ||||||
| import 'package:obtainium/components/generated_form.dart'; |  | ||||||
| import 'package:obtainium/providers/source_provider.dart'; |  | ||||||
|  |  | ||||||
| class APKMirror implements AppSource { |  | ||||||
|   @override |  | ||||||
|   late String host = 'apkmirror.com'; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   String standardizeURL(String url) { |  | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); |  | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |  | ||||||
|     if (match == null) { |  | ||||||
|       throw notValidURL(runtimeType.toString()); |  | ||||||
|     } |  | ||||||
|     return url.substring(0, match.end); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => |  | ||||||
|       '$standardUrl#whatsnew'; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { |  | ||||||
|     var originalUri = Uri.parse(apkUrl); |  | ||||||
|     var res = await get(originalUri); |  | ||||||
|     if (res.statusCode != 200) { |  | ||||||
|       throw false; |  | ||||||
|     } |  | ||||||
|     var href = |  | ||||||
|         parse(res.body).querySelector('.downloadButton')?.attributes['href']; |  | ||||||
|     if (href == null) { |  | ||||||
|       throw false; |  | ||||||
|     } |  | ||||||
|     var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: { |  | ||||||
|       'User-Agent': |  | ||||||
|           'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' |  | ||||||
|     }); |  | ||||||
|     if (res2.statusCode != 200) { |  | ||||||
|       throw false; |  | ||||||
|     } |  | ||||||
|     var links = parse(res2.body) |  | ||||||
|         .querySelectorAll('a') |  | ||||||
|         .where((element) => element.innerHtml == 'here') |  | ||||||
|         .map((e) => e.attributes['href']) |  | ||||||
|         .where((element) => element != null) |  | ||||||
|         .toList(); |  | ||||||
|     if (links.isEmpty) { |  | ||||||
|       throw false; |  | ||||||
|     } |  | ||||||
|     return '${originalUri.origin}${links[0]}'; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<APKDetails> getLatestAPKDetails( |  | ||||||
|       String standardUrl, List<String> additionalData) async { |  | ||||||
|     Response res = await get(Uri.parse('$standardUrl/feed')); |  | ||||||
|     if (res.statusCode != 200) { |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |  | ||||||
|     var nextUrl = parse(res.body) |  | ||||||
|         .querySelector('item') |  | ||||||
|         ?.querySelector('link') |  | ||||||
|         ?.nextElementSibling |  | ||||||
|         ?.innerHtml; |  | ||||||
|     if (nextUrl == null) { |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |  | ||||||
|     Response res2 = await get(Uri.parse(nextUrl), headers: { |  | ||||||
|       'User-Agent': |  | ||||||
|           'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' |  | ||||||
|     }); |  | ||||||
|     if (res2.statusCode != 200) { |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |  | ||||||
|     var html2 = parse(res2.body); |  | ||||||
|     var origin = Uri.parse(standardUrl).origin; |  | ||||||
|     List<String> apkUrls = html2 |  | ||||||
|         .querySelectorAll('.apkm-badge') |  | ||||||
|         .map((e) => e.innerHtml != 'APK' |  | ||||||
|             ? '' |  | ||||||
|             : e.previousElementSibling?.attributes['href'] ?? '') |  | ||||||
|         .where((element) => element.isNotEmpty) |  | ||||||
|         .map((e) => '$origin$e') |  | ||||||
|         .toList(); |  | ||||||
|     if (apkUrls.isEmpty) { |  | ||||||
|       throw noAPKFound; |  | ||||||
|     } |  | ||||||
|     var version = html2.querySelector('span.active.accent_color')?.innerHtml; |  | ||||||
|     if (version == null) { |  | ||||||
|       throw couldNotFindLatestVersion; |  | ||||||
|     } |  | ||||||
|     return APKDetails(version, apkUrls); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   AppNames getAppNames(String standardUrl) { |  | ||||||
|     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); |  | ||||||
|     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); |  | ||||||
|     return AppNames(names[1], names[2]); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } |  | ||||||
| @@ -1,11 +1,13 @@ | |||||||
| import 'package:html/parser.dart'; | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:http/http.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class FDroid implements AppSource { | class FDroid extends AppSource { | ||||||
|   @override |   FDroid() { | ||||||
|   late String host = 'f-droid.org'; |     host = 'f-droid.org'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
| @@ -18,7 +20,7 @@ class FDroid implements AppSource { | |||||||
|     RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); |     RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); | ||||||
|     match = standardUrlRegExA.firstMatch(url.toLowerCase()); |     match = standardUrlRegExA.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -27,46 +29,45 @@ class FDroid implements AppSource { | |||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |   String? tryInferringAppId(String standardUrl) { | ||||||
|  |     return Uri.parse(standardUrl).pathSegments.last; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|  |       Response res, String apkUrlPrefix) { | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       List<dynamic> releases = jsonDecode(res.body)['packages'] ?? []; | ||||||
|  |       if (releases.isEmpty) { | ||||||
|  |         throw NoReleasesError(); | ||||||
|  |       } | ||||||
|  |       String? latestVersion = releases[0]['versionName']; | ||||||
|  |       if (latestVersion == null) { | ||||||
|  |         throw NoVersionError(); | ||||||
|  |       } | ||||||
|  |       List<String> apkUrls = releases | ||||||
|  |           .where((element) => element['versionName'] == latestVersion) | ||||||
|  |           .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') | ||||||
|  |           .toList(); | ||||||
|  |       if (apkUrls.isEmpty) { | ||||||
|  |         throw NoAPKError(); | ||||||
|  |       } | ||||||
|  |       return APKDetails(latestVersion, apkUrls); | ||||||
|  |     } else { | ||||||
|  |       throw NoReleasesError(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
|     Response res = await get(Uri.parse(standardUrl)); |     String? appId = tryInferringAppId(standardUrl); | ||||||
|     if (res.statusCode == 200) { |     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|       var latestReleaseDiv = |         await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')), | ||||||
|           parse(res.body).querySelector('#latest.package-version'); |         'https://f-droid.org/repo/$appId'); | ||||||
|       var apkUrl = latestReleaseDiv |  | ||||||
|           ?.querySelector('.package-version-download a') |  | ||||||
|           ?.attributes['href']; |  | ||||||
|       if (apkUrl == null) { |  | ||||||
|         throw noAPKFound; |  | ||||||
|       } |  | ||||||
|       var version = latestReleaseDiv |  | ||||||
|           ?.querySelector('.package-version-header b') |  | ||||||
|           ?.innerHtml |  | ||||||
|           .split(' ') |  | ||||||
|           .last; |  | ||||||
|       if (version == null) { |  | ||||||
|         throw couldNotFindLatestVersion; |  | ||||||
|       } |  | ||||||
|       return APKDetails(version, [apkUrl]); |  | ||||||
|     } else { |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   AppNames getAppNames(String standardUrl) { |   AppNames getAppNames(String standardUrl) { | ||||||
|     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); |     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,16 +7,88 @@ import 'package:obtainium/providers/settings_provider.dart'; | |||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class GitHub implements AppSource { | class GitHub extends AppSource { | ||||||
|   @override |   GitHub() { | ||||||
|   late String host = 'github.com'; |     host = 'github.com'; | ||||||
|  |  | ||||||
|  |     additionalDataDefaults = ['true', 'true', '']; | ||||||
|  |  | ||||||
|  |     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), | ||||||
|  |                 )) | ||||||
|  |           ]) | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     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; | ||||||
|  |               } | ||||||
|  |             ]) | ||||||
|  |       ] | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     canSearch = true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -33,9 +105,6 @@ class GitHub implements AppSource { | |||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|       '$standardUrl/releases'; |       '$standardUrl/releases'; | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -69,6 +138,7 @@ class GitHub implements AppSource { | |||||||
|         if (!includePrereleases && releases[i]['prerelease'] == true) { |         if (!includePrereleases && releases[i]['prerelease'] == true) { | ||||||
|           continue; |           continue; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (regexFilter != null && |         if (regexFilter != null && | ||||||
|             !RegExp(regexFilter) |             !RegExp(regexFilter) | ||||||
|                 .hasMatch((releases[i]['name'] as String).trim())) { |                 .hasMatch((releases[i]['name'] as String).trim())) { | ||||||
| @@ -83,25 +153,19 @@ class GitHub implements AppSource { | |||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|       if (targetRelease == null) { |       if (targetRelease == null) { | ||||||
|         throw couldNotFindReleases; |         throw NoReleasesError(); | ||||||
|       } |       } | ||||||
|       if ((targetRelease['apkUrls'] as List<String>).isEmpty) { |       if ((targetRelease['apkUrls'] as List<String>).isEmpty) { | ||||||
|         throw noAPKFound; |         throw NoAPKError(); | ||||||
|       } |       } | ||||||
|       String? version = targetRelease['tag_name']; |       String? version = targetRelease['tag_name']; | ||||||
|       if (version == null) { |       if (version == null) { | ||||||
|         throw couldNotFindLatestVersion; |         throw NoVersionError(); | ||||||
|       } |       } | ||||||
|       return APKDetails(version, targetRelease['apkUrls']); |       return APKDetails(version, targetRelease['apkUrls']); | ||||||
|     } else { |     } else { | ||||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { |       rateLimitErrorCheck(res); | ||||||
|         throw RateLimitError( |       throw getObtainiumHttpError(res); | ||||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / |  | ||||||
|                     60000000) |  | ||||||
|                 .round()); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -113,72 +177,31 @@ class GitHub implements AppSource { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = [ |   Future<Map<String, String>> search(String query) async { | ||||||
|     [GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], |     Response res = await get(Uri.parse( | ||||||
|     [ |         'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100')); | ||||||
|       GeneratedFormItem( |     if (res.statusCode == 200) { | ||||||
|           label: 'Fallback to older releases', type: FormItemType.bool) |       Map<String, String> urlsWithDescriptions = {}; | ||||||
|     ], |       for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) { | ||||||
|     [ |         urlsWithDescriptions.addAll({ | ||||||
|       GeneratedFormItem( |           e['html_url'] as String: e['description'] != null | ||||||
|           label: 'Filter Release Titles by Regular Expression', |               ? e['description'] as String | ||||||
|           type: FormItemType.string, |               : 'No description' | ||||||
|           required: false, |         }); | ||||||
|           additionalValidators: [ |       } | ||||||
|             (value) { |       return urlsWithDescriptions; | ||||||
|               if (value == null || value.isEmpty) { |     } else { | ||||||
|                 return null; |       rateLimitErrorCheck(res); | ||||||
|               } |       throw getObtainiumHttpError(res); | ||||||
|               try { |     } | ||||||
|                 RegExp(value); |   } | ||||||
|               } catch (e) { |  | ||||||
|                 return 'Invalid regular expression'; |  | ||||||
|               } |  | ||||||
|               return null; |  | ||||||
|             } |  | ||||||
|           ]) |  | ||||||
|     ] |  | ||||||
|   ]; |  | ||||||
|  |  | ||||||
|   @override |   rateLimitErrorCheck(Response res) { | ||||||
|   List<String> additionalDataDefaults = ['true', 'true', '']; |     if (res.headers['x-ratelimit-remaining'] == '0') { | ||||||
|  |       throw RateLimitError( | ||||||
|   @override |           (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = [ |                   60000000) | ||||||
|     GeneratedFormItem( |               .round()); | ||||||
|         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), |  | ||||||
|               )) |  | ||||||
|         ]) |  | ||||||
|   ]; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,19 +1,20 @@ | |||||||
| import 'package:html/parser.dart'; | import 'package:html/parser.dart'; | ||||||
| import 'package:http/http.dart'; | import 'package:http/http.dart'; | ||||||
| import 'package:obtainium/app_sources/github.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class GitLab implements AppSource { | class GitLab extends AppSource { | ||||||
|   @override |   GitLab() { | ||||||
|   late String host = 'gitlab.com'; |     host = 'gitlab.com'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -22,9 +23,6 @@ class GitLab implements AppSource { | |||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|       '$standardUrl/-/releases'; |       '$standardUrl/-/releases'; | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -39,7 +37,9 @@ class GitLab implements AppSource { | |||||||
|         ...getLinksFromParsedHTML( |         ...getLinksFromParsedHTML( | ||||||
|             entryContent, |             entryContent, | ||||||
|             RegExp( |             RegExp( | ||||||
|                 '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', |                 '^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||||
|  |                   return '\\${x[0]}'; | ||||||
|  |                 })}/uploads/[^/]+/[^/]+\\.apk\$', | ||||||
|                 caseSensitive: false), |                 caseSensitive: false), | ||||||
|             standardUri.origin), |             standardUri.origin), | ||||||
|         // GitLab releases may contain links to externally hosted APKs |         // GitLab releases may contain links to externally hosted APKs | ||||||
| @@ -49,18 +49,18 @@ class GitLab implements AppSource { | |||||||
|             .toList() |             .toList() | ||||||
|       ]; |       ]; | ||||||
|       if (apkUrlList.isEmpty) { |       if (apkUrlList.isEmpty) { | ||||||
|         throw noAPKFound; |         throw NoAPKError(); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       var entryId = entry?.querySelector('id')?.innerHtml; |       var entryId = entry?.querySelector('id')?.innerHtml; | ||||||
|       var version = |       var version = | ||||||
|           entryId == null ? null : Uri.parse(entryId).pathSegments.last; |           entryId == null ? null : Uri.parse(entryId).pathSegments.last; | ||||||
|       if (version == null) { |       if (version == null) { | ||||||
|         throw couldNotFindLatestVersion; |         throw NoVersionError(); | ||||||
|       } |       } | ||||||
|       return APKDetails(version, apkUrlList); |       return APKDetails(version, apkUrlList); | ||||||
|     } else { |     } else { | ||||||
|       throw couldNotFindReleases; |       throw NoReleasesError(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -69,13 +69,4 @@ class GitLab implements AppSource { | |||||||
|     // Same as GitHub |     // Same as GitHub | ||||||
|     return GitHub().getAppNames(standardUrl); |     return GitHub().getAppNames(standardUrl); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,18 +1,19 @@ | |||||||
| import 'package:html/parser.dart'; |  | ||||||
| import 'package:http/http.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class IzzyOnDroid implements AppSource { | class IzzyOnDroid extends AppSource { | ||||||
|   @override |   IzzyOnDroid() { | ||||||
|   late String host = 'android.izzysoft.de'; |     host = 'android.izzysoft.de'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); |     RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -21,54 +22,22 @@ class IzzyOnDroid implements AppSource { | |||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |   String? tryInferringAppId(String standardUrl) { | ||||||
|  |     return FDroid().tryInferringAppId(standardUrl); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
|     Response res = await get(Uri.parse(standardUrl)); |     String? appId = tryInferringAppId(standardUrl); | ||||||
|     if (res.statusCode == 200) { |     return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( | ||||||
|       var parsedHtml = parse(res.body); |         await get( | ||||||
|       var multipleVersionApkUrls = parsedHtml |             Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')), | ||||||
|           .querySelectorAll('a') |         'https://android.izzysoft.de/frepo/$appId'); | ||||||
|           .where((element) => |  | ||||||
|               element.attributes['href']?.toLowerCase().endsWith('.apk') ?? |  | ||||||
|               false) |  | ||||||
|           .map((e) => 'https://$host${e.attributes['href'] ?? ''}') |  | ||||||
|           .toList(); |  | ||||||
|       if (multipleVersionApkUrls.isEmpty) { |  | ||||||
|         throw noAPKFound; |  | ||||||
|       } |  | ||||||
|       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 couldNotFindLatestVersion; |  | ||||||
|       } |  | ||||||
|       return APKDetails(version, [multipleVersionApkUrls[0]]); |  | ||||||
|     } else { |  | ||||||
|       throw couldNotFindReleases; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   AppNames getAppNames(String standardUrl) { |   AppNames getAppNames(String standardUrl) { | ||||||
|     return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); |     return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,18 +1,19 @@ | |||||||
| import 'package:html/parser.dart'; | import 'package:html/parser.dart'; | ||||||
| import 'package:http/http.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class Mullvad implements AppSource { | class Mullvad extends AppSource { | ||||||
|   @override |   Mullvad() { | ||||||
|   late String host = 'mullvad.net'; |     host = 'mullvad.net'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host'); |     RegExp standardUrlRegEx = RegExp('^https?://$host'); | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -21,9 +22,6 @@ class Mullvad implements AppSource { | |||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => |   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||||
|       'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; |       'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -36,12 +34,12 @@ class Mullvad implements AppSource { | |||||||
|           ?.split('/') |           ?.split('/') | ||||||
|           .last; |           .last; | ||||||
|       if (version == null) { |       if (version == null) { | ||||||
|         throw couldNotFindLatestVersion; |         throw NoVersionError(); | ||||||
|       } |       } | ||||||
|       return APKDetails( |       return APKDetails( | ||||||
|           version, ['https://mullvad.net/download/app/apk/latest']); |           version, ['https://mullvad.net/download/app/apk/latest']); | ||||||
|     } else { |     } else { | ||||||
|       throw couldNotFindReleases; |       throw NoReleasesError(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -49,13 +47,4 @@ class Mullvad implements AppSource { | |||||||
|   AppNames getAppNames(String standardUrl) { |   AppNames getAppNames(String standardUrl) { | ||||||
|     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); |     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
| import 'package:http/http.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class Signal implements AppSource { | class Signal extends AppSource { | ||||||
|   @override |   Signal() { | ||||||
|   late String host = 'signal.org'; |     host = 'signal.org'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
| @@ -15,9 +16,6 @@ class Signal implements AppSource { | |||||||
|   @override |   @override | ||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -27,27 +25,18 @@ class Signal implements AppSource { | |||||||
|       var json = jsonDecode(res.body); |       var json = jsonDecode(res.body); | ||||||
|       String? apkUrl = json['url']; |       String? apkUrl = json['url']; | ||||||
|       if (apkUrl == null) { |       if (apkUrl == null) { | ||||||
|         throw noAPKFound; |         throw NoAPKError(); | ||||||
|       } |       } | ||||||
|       String? version = json['versionName']; |       String? version = json['versionName']; | ||||||
|       if (version == null) { |       if (version == null) { | ||||||
|         throw couldNotFindLatestVersion; |         throw NoVersionError(); | ||||||
|       } |       } | ||||||
|       return APKDetails(version, [apkUrl]); |       return APKDetails(version, [apkUrl]); | ||||||
|     } else { |     } else { | ||||||
|       throw couldNotFindReleases; |       throw NoReleasesError(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); |   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,18 +1,19 @@ | |||||||
| import 'package:html/parser.dart'; | import 'package:html/parser.dart'; | ||||||
| import 'package:http/http.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'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class SourceForge implements AppSource { | class SourceForge extends AppSource { | ||||||
|   @override |   SourceForge() { | ||||||
|   late String host = 'sourceforge.net'; |     host = 'sourceforge.net'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String standardizeURL(String url) { |   String standardizeURL(String url) { | ||||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); |     RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+'); | ||||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|     if (match == null) { |     if (match == null) { | ||||||
|       throw notValidURL(runtimeType.toString()); |       throw InvalidURLError(runtimeType.toString()); | ||||||
|     } |     } | ||||||
|     return url.substring(0, match.end); |     return url.substring(0, match.end); | ||||||
|   } |   } | ||||||
| @@ -20,9 +21,6 @@ class SourceForge implements AppSource { | |||||||
|   @override |   @override | ||||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => null; |   String? changeLogPageFromStandardUrl(String standardUrl) => null; | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl; |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData) async { |       String standardUrl, List<String> additionalData) async { | ||||||
| @@ -42,7 +40,7 @@ class SourceForge implements AppSource { | |||||||
|  |  | ||||||
|       String? version = getVersion(allDownloadLinks[0]); |       String? version = getVersion(allDownloadLinks[0]); | ||||||
|       if (version == null) { |       if (version == null) { | ||||||
|         throw couldNotFindLatestVersion; |         throw NoVersionError(); | ||||||
|       } |       } | ||||||
|       var apkUrlListAllReleases = allDownloadLinks |       var apkUrlListAllReleases = allDownloadLinks | ||||||
|           .where((element) => element.toLowerCase().endsWith('.apk/download')) |           .where((element) => element.toLowerCase().endsWith('.apk/download')) | ||||||
| @@ -52,11 +50,11 @@ class SourceForge implements AppSource { | |||||||
|               .where((element) => getVersion(element) == version) |               .where((element) => getVersion(element) == version) | ||||||
|               .toList(); |               .toList(); | ||||||
|       if (apkUrlList.isEmpty) { |       if (apkUrlList.isEmpty) { | ||||||
|         throw noAPKFound; |         throw NoAPKError(); | ||||||
|       } |       } | ||||||
|       return APKDetails(version, apkUrlList); |       return APKDetails(version, apkUrlList); | ||||||
|     } else { |     } else { | ||||||
|       throw couldNotFindReleases; |       throw NoReleasesError(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -65,13 +63,4 @@ class SourceForge implements AppSource { | |||||||
|     return AppNames(runtimeType.toString(), |     return AppNames(runtimeType.toString(), | ||||||
|         standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); |         standardUrl.substring(standardUrl.lastIndexOf('/') + 1)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<String> additionalDataDefaults = []; |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   List<GeneratedFormItem> moreSourceSettingsFormItems = []; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; | |||||||
|  |  | ||||||
| enum FormItemType { string, bool } | 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 { | class GeneratedFormItem { | ||||||
|   late String label; |   late String label; | ||||||
| @@ -13,6 +14,7 @@ class GeneratedFormItem { | |||||||
|   late String id; |   late String id; | ||||||
|   late List<Widget> belowWidgets; |   late List<Widget> belowWidgets; | ||||||
|   late String? hint; |   late String? hint; | ||||||
|  |   late List<String>? opts; | ||||||
|  |  | ||||||
|   GeneratedFormItem( |   GeneratedFormItem( | ||||||
|       {this.label = 'Input', |       {this.label = 'Input', | ||||||
| @@ -22,7 +24,8 @@ class GeneratedFormItem { | |||||||
|       this.additionalValidators = const [], |       this.additionalValidators = const [], | ||||||
|       this.id = 'input', |       this.id = 'input', | ||||||
|       this.belowWidgets = const [], |       this.belowWidgets = const [], | ||||||
|       this.hint}); |       this.hint, | ||||||
|  |       this.opts}); | ||||||
| } | } | ||||||
|  |  | ||||||
| class GeneratedForm extends StatefulWidget { | class GeneratedForm extends StatefulWidget { | ||||||
| @@ -47,7 +50,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|   List<List<Widget>> rows = []; |   List<List<Widget>> rows = []; | ||||||
|  |  | ||||||
|   // If any value changes, call this to update the parent with value and validity |   // If any value changes, call this to update the parent with value and validity | ||||||
|   void someValueChanged() { |   void someValueChanged({bool isBuilding = false}) { | ||||||
|     List<String> returnValues = []; |     List<String> returnValues = []; | ||||||
|     var valid = true; |     var valid = true; | ||||||
|     for (int r = 0; r < values.length; r++) { |     for (int r = 0; r < values.length; r++) { | ||||||
| @@ -62,7 +65,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     widget.onValueChanges(returnValues, valid); |     widget.onValueChanges(returnValues, valid, isBuilding); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -75,14 +78,16 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|         .map((row) => row.map((e) { |         .map((row) => row.map((e) { | ||||||
|               return j < widget.defaultValues.length |               return j < widget.defaultValues.length | ||||||
|                   ? widget.defaultValues[j++] |                   ? widget.defaultValues[j++] | ||||||
|                   : ''; |                   : e.opts != null | ||||||
|  |                       ? e.opts!.first | ||||||
|  |                       : ''; | ||||||
|             }).toList()) |             }).toList()) | ||||||
|         .toList(); |         .toList(); | ||||||
|  |  | ||||||
|     // Dynamically create form inputs |     // Dynamically create form inputs | ||||||
|     formInputs = widget.items.asMap().entries.map((row) { |     formInputs = widget.items.asMap().entries.map((row) { | ||||||
|       return row.value.asMap().entries.map((e) { |       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>(); |           final formFieldKey = GlobalKey<FormFieldState>(); | ||||||
|           return TextFormField( |           return TextFormField( | ||||||
|             key: formFieldKey, |             key: formFieldKey, | ||||||
| @@ -112,11 +117,29 @@ class _GeneratedFormState extends State<GeneratedForm> { | |||||||
|               return null; |               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 { |         } else { | ||||||
|           return Container(); // Some input types added in build |           return Container(); // Some input types added in build | ||||||
|         } |         } | ||||||
|       }).toList(); |       }).toList(); | ||||||
|     }).toList(); |     }).toList(); | ||||||
|  |     someValueChanged(isBuilding: true); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   | |||||||
| @@ -28,6 +28,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | |||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|  |     values = widget.defaultValues; | ||||||
|     valid = widget.initValid; |     valid = widget.initValid; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -45,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | |||||||
|           ), |           ), | ||||||
|         GeneratedForm( |         GeneratedForm( | ||||||
|             items: widget.items, |             items: widget.items, | ||||||
|             onValueChanges: (values, valid) { |             onValueChanges: (values, valid, isBuilding) { | ||||||
|               setState(() { |               if (isBuilding) { | ||||||
|                 this.values = values; |                 this.values = values; | ||||||
|                 this.valid = valid; |                 this.valid = valid; | ||||||
|               }); |               } else { | ||||||
|  |                 setState(() { | ||||||
|  |                   this.values = values; | ||||||
|  |                   this.valid = valid; | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|             }, |             }, | ||||||
|             defaultValues: widget.defaultValues) |             defaultValues: widget.defaultValues) | ||||||
|       ]), |       ]), | ||||||
|   | |||||||
| @@ -1,3 +1,17 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:obtainium/providers/logs_provider.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
|  | class ObtainiumError { | ||||||
|  |   late String message; | ||||||
|  |   bool unexpected; | ||||||
|  |   ObtainiumError(this.message, {this.unexpected = false}); | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     return message; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class RateLimitError { | class RateLimitError { | ||||||
|   late int remainingMinutes; |   late int remainingMinutes; | ||||||
|   RateLimitError(this.remainingMinutes); |   RateLimitError(this.remainingMinutes); | ||||||
| @@ -6,3 +20,103 @@ class RateLimitError { | |||||||
|   String toString() => |   String toString() => | ||||||
|       'Too many requests (rate limited) - try again in $remainingMinutes minutes'; |       'Too many requests (rate limited) - try again in $remainingMinutes minutes'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class InvalidURLError extends ObtainiumError { | ||||||
|  |   InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoReleasesError extends ObtainiumError { | ||||||
|  |   NoReleasesError() : super('Could not find a suitable release'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoAPKError extends ObtainiumError { | ||||||
|  |   NoAPKError() : super('Could not find a suitable release'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class NoVersionError extends ObtainiumError { | ||||||
|  |   NoVersionError() : super('Could not determine release version'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class UnsupportedURLError extends ObtainiumError { | ||||||
|  |   UnsupportedURLError() : super('URL does not match a known source'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class DowngradeError extends ObtainiumError { | ||||||
|  |   DowngradeError() : super('Cannot install an older version of an App'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class IDChangedError extends ObtainiumError { | ||||||
|  |   IDChangedError() | ||||||
|  |       : super('Downloaded package ID does not match existing App ID'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class 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', unexpected: true); | ||||||
|  |  | ||||||
|  |   add(String appId, String string) { | ||||||
|  |     var tempIds = content.remove(string); | ||||||
|  |     tempIds ??= []; | ||||||
|  |     tempIds.add(appId); | ||||||
|  |     content.putIfAbsent(string, () => tempIds!); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String toString() { | ||||||
|  |     String finalString = ''; | ||||||
|  |     for (var e in content.keys) { | ||||||
|  |       finalString += '$e: ${content[e].toString()}\n\n'; | ||||||
|  |     } | ||||||
|  |     return finalString; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | showError(dynamic e, BuildContext context) { | ||||||
|  |   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())), | ||||||
|  |     ); | ||||||
|  |   } else { | ||||||
|  |     showDialog( | ||||||
|  |         context: context, | ||||||
|  |         builder: (BuildContext ctx) { | ||||||
|  |           return AlertDialog( | ||||||
|  |             scrollable: true, | ||||||
|  |             title: Text(e is MultiAppMultiError | ||||||
|  |                 ? 'Some Errors Occurred' | ||||||
|  |                 : 'Unexpected Error'), | ||||||
|  |             content: Text(e.toString()), | ||||||
|  |             actions: [ | ||||||
|  |               TextButton( | ||||||
|  |                   onPressed: () { | ||||||
|  |                     Navigator.of(context).pop(null); | ||||||
|  |                   }, | ||||||
|  |                   child: const Text('Ok')), | ||||||
|  |             ], | ||||||
|  |           ); | ||||||
|  |         }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | String list2FriendlyString(List<String> list) { | ||||||
|  |   return list.length == 2 | ||||||
|  |       ? '${list[0]} and ${list[1]}' | ||||||
|  |       : list | ||||||
|  |           .asMap() | ||||||
|  |           .entries | ||||||
|  |           .map((e) => | ||||||
|  |               e.value + | ||||||
|  |               (e.key == list.length - 1 | ||||||
|  |                   ? '' | ||||||
|  |                   : e.key == list.length - 2 | ||||||
|  |                       ? ', and ' | ||||||
|  |                       : ', ')) | ||||||
|  |           .join(''); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,61 +1,67 @@ | |||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  | import 'dart:math'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/custom_errors.dart'; | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/home.dart'; | import 'package:obtainium/pages/home.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/logs_provider.dart'; | ||||||
| import 'package:obtainium/providers/notifications_provider.dart'; | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:permission_handler/permission_handler.dart'; | import 'package:permission_handler/permission_handler.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; |  | ||||||
| import 'package:dynamic_color/dynamic_color.dart'; | import 'package:dynamic_color/dynamic_color.dart'; | ||||||
| import 'package:device_info_plus/device_info_plus.dart'; | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
|  | import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; | ||||||
|  |  | ||||||
| const String currentVersion = '0.6.0'; | const String currentVersion = '0.7.5'; | ||||||
| const String currentReleaseTag = | const String currentReleaseTag = | ||||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES |     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||||
|  |  | ||||||
| const String bgUpdateCheckTaskName = 'bg-update-check'; | const int bgUpdateCheckAlarmId = 666; | ||||||
|  |  | ||||||
| bgUpdateCheck(int? ignoreAfterMicroseconds) async { | @pragma('vm:entry-point') | ||||||
|  | Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||||
|  |   LogsProvider logs = LogsProvider(); | ||||||
|  |   logs.add('Started BG update check task'); | ||||||
|  |   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|  |   await AndroidAlarmManager.initialize(); | ||||||
|   DateTime? ignoreAfter = ignoreAfterMicroseconds != null |   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||||
|       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) |       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||||
|       : null; |       : null; | ||||||
|  |   logs.add('Bg update ignoreAfter is $ignoreAfter'); | ||||||
|   var notificationsProvider = NotificationsProvider(); |   var notificationsProvider = NotificationsProvider(); | ||||||
|   await notificationsProvider.notify(checkingUpdatesNotification); |   await notificationsProvider.notify(checkingUpdatesNotification); | ||||||
|   try { |   try { | ||||||
|     var appsProvider = AppsProvider(); |     var appsProvider = AppsProvider(forBGTask: true); | ||||||
|     await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); |     await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); | ||||||
|     await appsProvider.loadApps(shouldCorrectInstallStatus: false); |     await appsProvider.loadApps(); | ||||||
|     List<String> existingUpdateIds = |     List<String> existingUpdateIds = | ||||||
|         appsProvider.getExistingUpdates(installedOnly: true); |         appsProvider.findExistingUpdates(installedOnly: true); | ||||||
|     DateTime nextIgnoreAfter = DateTime.now(); |     DateTime nextIgnoreAfter = DateTime.now(); | ||||||
|     String? err; |     String? err; | ||||||
|     try { |     try { | ||||||
|  |       logs.add('Started actual BG update checking'); | ||||||
|       await appsProvider.checkUpdates( |       await appsProvider.checkUpdates( | ||||||
|           ignoreAfter: ignoreAfter, |           ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); | ||||||
|           immediatelyThrowRateLimitError: true, |  | ||||||
|           immediatelyThrowSocketError: true, |  | ||||||
|           shouldCorrectInstallStatus: false); |  | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       if (e is RateLimitError || e is SocketException) { |       if (e is RateLimitError || e is SocketException) { | ||||||
|         String nextTaskName = |         var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; | ||||||
|             '$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; |         logs.add( | ||||||
|         Workmanager().registerOneOffTask(nextTaskName, nextTaskName, |             'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes'); | ||||||
|             constraints: Constraints(networkType: NetworkType.connected), |         AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), | ||||||
|             initialDelay: Duration( |             Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { | ||||||
|                 minutes: e is RateLimitError ? e.remainingMinutes : 15), |           'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch | ||||||
|             inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); |         }); | ||||||
|       } else { |       } else { | ||||||
|         err = e.toString(); |         err = e.toString(); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     List<App> newUpdates = appsProvider |     List<App> newUpdates = appsProvider | ||||||
|         .getExistingUpdates(installedOnly: true) |         .findExistingUpdates(installedOnly: true) | ||||||
|         .where((id) => !existingUpdateIds.contains(id)) |         .where((id) => !existingUpdateIds.contains(id)) | ||||||
|         .map((e) => appsProvider.apps[e]!.app) |         .map((e) => appsProvider.apps[e]!.app) | ||||||
|         .toList(); |         .toList(); | ||||||
| @@ -73,31 +79,23 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async { | |||||||
|     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), |     //           silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), | ||||||
|     //       cancelExisting: true); |     //       cancelExisting: true); | ||||||
|     // } |     // } | ||||||
|  |     logs.add( | ||||||
|  |         'BG update checking found ${newUpdates.length} updates - will notify user if needed'); | ||||||
|     if (newUpdates.isNotEmpty) { |     if (newUpdates.isNotEmpty) { | ||||||
|       notificationsProvider.notify(UpdateNotification(newUpdates)); |       notificationsProvider.notify(UpdateNotification(newUpdates)); | ||||||
|     } |     } | ||||||
|     if (err != null) { |     if (err != null) { | ||||||
|       throw err; |       throw err; | ||||||
|     } |     } | ||||||
|     return Future.value(true); |  | ||||||
|   } catch (e) { |   } catch (e) { | ||||||
|     notificationsProvider |     notificationsProvider | ||||||
|         .notify(ErrorCheckingUpdatesNotification(e.toString())); |         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||||
|     return Future.error(false); |  | ||||||
|   } finally { |   } finally { | ||||||
|  |     logs.add('Finished BG update check task'); | ||||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); |     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @pragma('vm:entry-point') |  | ||||||
| void bgTaskCallback() { |  | ||||||
|   // Background process callback |  | ||||||
|   Workmanager().executeTask((task, inputData) async { |  | ||||||
|     return await bgUpdateCheck(inputData?['ignoreAfter']); |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { |   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||||
| @@ -106,18 +104,13 @@ void main() async { | |||||||
|     ); |     ); | ||||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); |     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||||
|   } |   } | ||||||
|   Workmanager().initialize( |   await AndroidAlarmManager.initialize(); | ||||||
|     bgTaskCallback, |  | ||||||
|   ); |  | ||||||
|   runApp(MultiProvider( |   runApp(MultiProvider( | ||||||
|     providers: [ |     providers: [ | ||||||
|       ChangeNotifierProvider( |       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||||
|           create: (context) => AppsProvider( |  | ||||||
|               shouldLoadApps: true, |  | ||||||
|               shouldCheckUpdatesAfterLoad: false, |  | ||||||
|               shouldDeleteAPKs: true)), |  | ||||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), |       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||||
|       Provider(create: (context) => NotificationsProvider()) |       Provider(create: (context) => NotificationsProvider()), | ||||||
|  |       Provider(create: (context) => LogsProvider()) | ||||||
|     ], |     ], | ||||||
|     child: const Obtainium(), |     child: const Obtainium(), | ||||||
|   )); |   )); | ||||||
| @@ -139,17 +132,19 @@ class _ObtainiumState extends State<Obtainium> { | |||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); |     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||||
|  |     LogsProvider logs = context.read<LogsProvider>(); | ||||||
|  |  | ||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } else { |     } else { | ||||||
|       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); |       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||||
|       if (isFirstRun) { |       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 |         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||||
|         Permission.notification.request(); |         Permission.notification.request(); | ||||||
|         appsProvider.saveApps([ |         appsProvider.saveApps([ | ||||||
|           App( |           App( | ||||||
|               'dev.imranr.obtainium', |               obtainiumId, | ||||||
|               'https://github.com/ImranR98/Obtainium', |               'https://github.com/ImranR98/Obtainium', | ||||||
|               'ImranR98', |               'ImranR98', | ||||||
|               'Obtainium', |               'Obtainium', | ||||||
| @@ -158,24 +153,26 @@ class _ObtainiumState extends State<Obtainium> { | |||||||
|               [], |               [], | ||||||
|               0, |               0, | ||||||
|               ['true'], |               ['true'], | ||||||
|               null) |               null, | ||||||
|  |               false) | ||||||
|         ]); |         ]); | ||||||
|       } |       } | ||||||
|       // Register the background update task according to the user's setting |       // Register the background update task according to the user's setting | ||||||
|       if (existingUpdateInterval != settingsProvider.updateInterval) { |       if (existingUpdateInterval != settingsProvider.updateInterval) { | ||||||
|  |         if (existingUpdateInterval != -1) { | ||||||
|  |           logs.add( | ||||||
|  |               'Setting update interval to ${settingsProvider.updateInterval}'); | ||||||
|  |         } | ||||||
|         existingUpdateInterval = settingsProvider.updateInterval; |         existingUpdateInterval = settingsProvider.updateInterval; | ||||||
|         if (existingUpdateInterval == 0) { |         if (existingUpdateInterval == 0) { | ||||||
|           Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); |           AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); | ||||||
|         } else { |         } else { | ||||||
|           Workmanager().registerPeriodicTask( |           AndroidAlarmManager.periodic( | ||||||
|               bgUpdateCheckTaskName, bgUpdateCheckTaskName, |               Duration(minutes: existingUpdateInterval), | ||||||
|               frequency: Duration(minutes: existingUpdateInterval), |               bgUpdateCheckAlarmId, | ||||||
|               initialDelay: Duration(minutes: existingUpdateInterval), |               bgUpdateCheck, | ||||||
|               constraints: Constraints(networkType: NetworkType.connected), |               rescheduleOnReboot: true, | ||||||
|               existingWorkPolicy: ExistingWorkPolicy.replace, |               wakeup: true); | ||||||
|               backoffPolicy: BackoffPolicy.linear, |  | ||||||
|               backoffPolicyDelay: |  | ||||||
|                   const Duration(minutes: minUpdateIntervalMinutes)); |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -5,47 +5,49 @@ import 'package:obtainium/app_sources/github.dart'; | |||||||
| import 'package:obtainium/custom_errors.dart'; | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  |  | ||||||
| class GitHubStars implements MassAppSource { | class GitHubStars implements MassAppUrlSource { | ||||||
|   @override |   @override | ||||||
|   late String name = 'GitHub Starred Repos'; |   late String name = 'GitHub Starred Repos'; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   late List<String> requiredArgs = ['Username']; |   late List<String> requiredArgs = ['Username']; | ||||||
|  |  | ||||||
|   Future<List<String>> getOnePageOfUserStarredUrls( |   Future<Map<String, String>> getOnePageOfUserStarredUrlsWithDescriptions( | ||||||
|       String username, int page) async { |       String username, int page) async { | ||||||
|     Response res = await get(Uri.parse( |     Response res = await get(Uri.parse( | ||||||
|         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); |         'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page')); | ||||||
|     if (res.statusCode == 200) { |     if (res.statusCode == 200) { | ||||||
|       return (jsonDecode(res.body) as List<dynamic>) |       Map<String, String> urlsWithDescriptions = {}; | ||||||
|           .map((e) => e['html_url'] as String) |       for (var e in (jsonDecode(res.body) as List<dynamic>)) { | ||||||
|           .toList(); |         urlsWithDescriptions.addAll({ | ||||||
|     } else { |           e['html_url'] as String: e['description'] != null | ||||||
|       if (res.headers['x-ratelimit-remaining'] == '0') { |               ? e['description'] as String | ||||||
|         throw RateLimitError( |               : 'No description' | ||||||
|             (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / |         }); | ||||||
|                     60000000) |  | ||||||
|                 .round()); |  | ||||||
|       } |       } | ||||||
|  |       return urlsWithDescriptions; | ||||||
|       throw 'Unable to find user\'s starred repos'; |     } else { | ||||||
|  |       var gh = GitHub(); | ||||||
|  |       gh.rateLimitErrorCheck(res); | ||||||
|  |       throw getObtainiumHttpError(res); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Future<List<String>> getUrls(List<String> args) async { |   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args) async { | ||||||
|     if (args.length != requiredArgs.length) { |     if (args.length != requiredArgs.length) { | ||||||
|       throw 'Wrong number of arguments provided'; |       throw ObtainiumError('Wrong number of arguments provided'); | ||||||
|     } |     } | ||||||
|     List<String> urls = []; |     Map<String, String> urlsWithDescriptions = {}; | ||||||
|     var page = 1; |     var page = 1; | ||||||
|     while (true) { |     while (true) { | ||||||
|       var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++); |       var pageUrls = | ||||||
|       urls.addAll(pageUrls); |           await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++); | ||||||
|  |       urlsWithDescriptions.addAll(pageUrls); | ||||||
|       if (pageUrls.length < 100) { |       if (pageUrls.length < 100) { | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return urls; |     return urlsWithDescriptions; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; | |||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/components/custom_app_bar.dart'; | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/pages/app.dart'; | import 'package:obtainium/pages/app.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| @@ -56,14 +57,16 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                               } catch (e) { |                                               } catch (e) { | ||||||
|                                                 return e is String |                                                 return e is String | ||||||
|                                                     ? e |                                                     ? e | ||||||
|                                                     : 'Error'; |                                                     : e is ObtainiumError | ||||||
|  |                                                         ? e.toString() | ||||||
|  |                                                         : 'Error'; | ||||||
|                                               } |                                               } | ||||||
|                                               return null; |                                               return null; | ||||||
|                                             } |                                             } | ||||||
|                                           ]) |                                           ]) | ||||||
|                                     ] |                                     ] | ||||||
|                                   ], |                                   ], | ||||||
|                                   onValueChanges: (values, valid) { |                                   onValueChanges: (values, valid, isBuilding) { | ||||||
|                                     setState(() { |                                     setState(() { | ||||||
|                                       userInput = values[0]; |                                       userInput = values[0]; | ||||||
|                                       var source = valid |                                       var source = valid | ||||||
| @@ -76,7 +79,7 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                             : []; |                                             : []; | ||||||
|                                         validAdditionalData = source != null |                                         validAdditionalData = source != null | ||||||
|                                             ? sourceProvider |                                             ? sourceProvider | ||||||
|                                                 .doesSourceHaveRequiredAdditionalData( |                                                 .ifSourceAppsRequireAdditionalData( | ||||||
|                                                     source) |                                                     source) | ||||||
|                                             : true; |                                             : true; | ||||||
|                                       } |                                       } | ||||||
| @@ -112,21 +115,27 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                                     additionalData); |                                                     additionalData); | ||||||
|                                             await settingsProvider |                                             await settingsProvider | ||||||
|                                                 .getInstallPermission(); |                                                 .getInstallPermission(); | ||||||
|                                             // ignore: use_build_context_synchronously |                                             // Only download the APK here if you need to for the package ID | ||||||
|                                             var apkUrl = await appsProvider |                                             if (sourceProvider | ||||||
|                                                 .selectApkUrl(app, context); |                                                 .isTempId(app.id)) { | ||||||
|                                             if (apkUrl == null) { |                                               // ignore: use_build_context_synchronously | ||||||
|                                               throw 'Cancelled'; |                                               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; | ||||||
|                                             } |                                             } | ||||||
|                                             app.preferredApkIndex = |  | ||||||
|                                                 app.apkUrls.indexOf(apkUrl); |  | ||||||
|                                             var downloadedApk = |  | ||||||
|                                                 await appsProvider |  | ||||||
|                                                     .downloadApp(app); |  | ||||||
|                                             app.id = downloadedApk.appId; |  | ||||||
|                                             if (appsProvider.apps |                                             if (appsProvider.apps | ||||||
|                                                 .containsKey(app.id)) { |                                                 .containsKey(app.id)) { | ||||||
|                                               throw 'App already added'; |                                               throw ObtainiumError( | ||||||
|  |                                                   'App already added'); | ||||||
|                                             } |                                             } | ||||||
|                                             await appsProvider.saveApps([app]); |                                             await appsProvider.saveApps([app]); | ||||||
|  |  | ||||||
| @@ -140,11 +149,7 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                                         AppPage( |                                                         AppPage( | ||||||
|                                                             appId: app.id))); |                                                             appId: app.id))); | ||||||
|                                           }).catchError((e) { |                                           }).catchError((e) { | ||||||
|                                             ScaffoldMessenger.of(context) |                                             showError(e, context); | ||||||
|                                                 .showSnackBar( |  | ||||||
|                                               SnackBar( |  | ||||||
|                                                   content: Text(e.toString())), |  | ||||||
|                                             ); |  | ||||||
|                                           }).whenComplete(() { |                                           }).whenComplete(() { | ||||||
|                                             setState(() { |                                             setState(() { | ||||||
|                                               gettingAppInfo = false; |                                               gettingAppInfo = false; | ||||||
| @@ -154,7 +159,8 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                   child: const Text('Add')) |                                   child: const Text('Add')) | ||||||
|                         ], |                         ], | ||||||
|                       ), |                       ), | ||||||
|                       if (pickedSource != null) |                       if (pickedSource != null && | ||||||
|  |                           pickedSource!.additionalDataDefaults.isNotEmpty) | ||||||
|                         Column( |                         Column( | ||||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, |                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                           children: [ |                           children: [ | ||||||
| @@ -173,7 +179,7 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                 .additionalDataFormItems.isNotEmpty) |                                 .additionalDataFormItems.isNotEmpty) | ||||||
|                               GeneratedForm( |                               GeneratedForm( | ||||||
|                                   items: pickedSource!.additionalDataFormItems, |                                   items: pickedSource!.additionalDataFormItems, | ||||||
|                                   onValueChanges: (values, valid) { |                                   onValueChanges: (values, valid, isBuilding) { | ||||||
|                                     setState(() { |                                     setState(() { | ||||||
|                                       additionalData = values; |                                       additionalData = values; | ||||||
|                                       validAdditionalData = valid; |                                       validAdditionalData = valid; | ||||||
| @@ -194,9 +200,6 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                                 crossAxisAlignment: CrossAxisAlignment.center, |                                 crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|                                 mainAxisAlignment: MainAxisAlignment.center, |                                 mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                                 children: [ |                                 children: [ | ||||||
|                               // const SizedBox( |  | ||||||
|                               //   height: 48, |  | ||||||
|                               // ), |  | ||||||
|                               const Text( |                               const Text( | ||||||
|                                 'Supported Sources:', |                                 'Supported Sources:', | ||||||
|                               ), |                               ), | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/components/generated_form_modal.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/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| @@ -25,10 +26,8 @@ class _AppPageState extends State<AppPage> { | |||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     var settingsProvider = context.watch<SettingsProvider>(); |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|     getUpdate(String id) { |     getUpdate(String id) { | ||||||
|       appsProvider.getUpdate(id).catchError((e) { |       appsProvider.checkUpdate(id).catchError((e) { | ||||||
|         ScaffoldMessenger.of(context).showSnackBar( |         showError(e, context); | ||||||
|           SnackBar(content: Text(e.toString())), |  | ||||||
|         ); |  | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -62,10 +61,14 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                 children: [ |                                 children: [ | ||||||
|                                     Image.memory( |                                     Image.memory( | ||||||
|                                       app!.installedInfo!.icon!, |                                       app!.installedInfo!.icon!, | ||||||
|                                       scale: 1.5, |                                       height: 150, | ||||||
|  |                                       gaplessPlayback: true, | ||||||
|                                     ) |                                     ) | ||||||
|                                   ]) |                                   ]) | ||||||
|                             : Container(), |                             : Container(), | ||||||
|  |                         const SizedBox( | ||||||
|  |                           height: 25, | ||||||
|  |                         ), | ||||||
|                         Text( |                         Text( | ||||||
|                           app?.installedInfo?.name ?? app?.app.name ?? 'App', |                           app?.installedInfo?.name ?? app?.app.name ?? 'App', | ||||||
|                           textAlign: TextAlign.center, |                           textAlign: TextAlign.center, | ||||||
| @@ -213,9 +216,8 @@ class _AppPageState extends State<AppPage> { | |||||||
|                         Expanded( |                         Expanded( | ||||||
|                             child: ElevatedButton( |                             child: ElevatedButton( | ||||||
|                                 onPressed: (app?.app.installedVersion == null || |                                 onPressed: (app?.app.installedVersion == null || | ||||||
|                                             appsProvider |                                             app?.app.installedVersion != | ||||||
|                                                 .checkAppObjectForUpdate( |                                                 app?.app.latestVersion) && | ||||||
|                                                     app!.app)) && |  | ||||||
|                                         !appsProvider.areDownloadsRunning() |                                         !appsProvider.areDownloadsRunning() | ||||||
|                                     ? () { |                                     ? () { | ||||||
|                                         HapticFeedback.heavyImpact(); |                                         HapticFeedback.heavyImpact(); | ||||||
| @@ -227,11 +229,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                             Navigator.of(context).pop(); |                                             Navigator.of(context).pop(); | ||||||
|                                           } |                                           } | ||||||
|                                         }).catchError((e) { |                                         }).catchError((e) { | ||||||
|                                           ScaffoldMessenger.of(context) |                                           showError(e, context); | ||||||
|                                               .showSnackBar( |  | ||||||
|                                             SnackBar( |  | ||||||
|                                                 content: Text(e.toString())), |  | ||||||
|                                           ); |  | ||||||
|                                         }); |                                         }); | ||||||
|                                       } |                                       } | ||||||
|                                     : null, |                                     : null, | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; | |||||||
| import 'package:obtainium/components/custom_app_bar.dart'; | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
| import 'package:obtainium/components/generated_form_modal.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/app.dart'; | ||||||
| import 'package:obtainium/providers/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| @@ -22,23 +23,24 @@ class AppsPageState extends State<AppsPage> { | |||||||
|   AppsFilter? filter; |   AppsFilter? filter; | ||||||
|   var updatesOnlyFilter = |   var updatesOnlyFilter = | ||||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); |       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||||
|   Set<String> selectedIds = {}; |   Set<App> selectedApps = {}; | ||||||
|  |   DateTime? refreshingSince; | ||||||
|  |  | ||||||
|   clearSelected() { |   clearSelected() { | ||||||
|     if (selectedIds.isNotEmpty) { |     if (selectedApps.isNotEmpty) { | ||||||
|       setState(() { |       setState(() { | ||||||
|         selectedIds.clear(); |         selectedApps.clear(); | ||||||
|       }); |       }); | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|     return false; |     return false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   selectThese(List<String> appIds) { |   selectThese(List<App> apps) { | ||||||
|     if (selectedIds.isEmpty) { |     if (selectedApps.isEmpty) { | ||||||
|       setState(() { |       setState(() { | ||||||
|         for (var a in appIds) { |         for (var a in apps) { | ||||||
|           selectedIds.add(a); |           selectedApps.add(a); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| @@ -52,16 +54,16 @@ class AppsPageState extends State<AppsPage> { | |||||||
|     var currentFilterIsUpdatesOnly = |     var currentFilterIsUpdatesOnly = | ||||||
|         filter?.isIdenticalTo(updatesOnlyFilter) ?? false; |         filter?.isIdenticalTo(updatesOnlyFilter) ?? false; | ||||||
|  |  | ||||||
|     selectedIds = selectedIds |     selectedApps = selectedApps | ||||||
|         .where((element) => sortedApps.map((e) => e.app.id).contains(element)) |         .where((element) => sortedApps.map((e) => e.app).contains(element)) | ||||||
|         .toSet(); |         .toSet(); | ||||||
|  |  | ||||||
|     toggleAppSelected(String appId) { |     toggleAppSelected(App app) { | ||||||
|       setState(() { |       setState(() { | ||||||
|         if (selectedIds.contains(appId)) { |         if (selectedApps.contains(app)) { | ||||||
|           selectedIds.remove(appId); |           selectedApps.remove(app); | ||||||
|         } else { |         } else { | ||||||
|           selectedIds.add(appId); |           selectedApps.add(app); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
| @@ -119,28 +121,57 @@ class AppsPageState extends State<AppsPage> { | |||||||
|       sortedApps = sortedApps.reversed.toList(); |       sortedApps = sortedApps.reversed.toList(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     var existingUpdateIdsAllOrSelected = appsProvider |     var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); | ||||||
|         .getExistingUpdates(installedOnly: true) |  | ||||||
|         .where((element) => selectedIds.isEmpty |     var existingUpdateIdsAllOrSelected = existingUpdates | ||||||
|  |         .where((element) => selectedApps.isEmpty | ||||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty |             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||||
|             : selectedIds.contains(element)) |             : selectedApps.map((e) => e.id).contains(element)) | ||||||
|         .toList(); |         .toList(); | ||||||
|     var newInstallIdsAllOrSelected = appsProvider |     var newInstallIdsAllOrSelected = appsProvider | ||||||
|         .getExistingUpdates(nonInstalledOnly: true) |         .findExistingUpdates(nonInstalledOnly: true) | ||||||
|         .where((element) => selectedIds.isEmpty |         .where((element) => selectedApps.isEmpty | ||||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty |             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||||
|             : selectedIds.contains(element)) |             : selectedApps.map((e) => e.id).contains(element)) | ||||||
|         .toList(); |         .toList(); | ||||||
|  |  | ||||||
|  |     if (settingsProvider.pinUpdates) { | ||||||
|  |       var temp = []; | ||||||
|  |       sortedApps = sortedApps.where((sa) { | ||||||
|  |         if (existingUpdates.contains(sa.app.id)) { | ||||||
|  |           temp.add(sa); | ||||||
|  |           return false; | ||||||
|  |         } | ||||||
|  |         return true; | ||||||
|  |       }).toList(); | ||||||
|  |       sortedApps = [...temp, ...sortedApps]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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( |     return Scaffold( | ||||||
|       backgroundColor: Theme.of(context).colorScheme.surface, |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|       body: RefreshIndicator( |       body: RefreshIndicator( | ||||||
|           onRefresh: () { |           onRefresh: () { | ||||||
|             HapticFeedback.lightImpact(); |             HapticFeedback.lightImpact(); | ||||||
|  |             setState(() { | ||||||
|  |               refreshingSince = DateTime.now(); | ||||||
|  |             }); | ||||||
|             return appsProvider.checkUpdates().catchError((e) { |             return appsProvider.checkUpdates().catchError((e) { | ||||||
|               ScaffoldMessenger.of(context).showSnackBar( |               showError(e, context); | ||||||
|                 SnackBar(content: Text(e.toString())), |             }).whenComplete(() { | ||||||
|               ); |               setState(() { | ||||||
|  |                 refreshingSince = null; | ||||||
|  |               }); | ||||||
|             }); |             }); | ||||||
|           }, |           }, | ||||||
|           child: CustomScrollView(slivers: <Widget>[ |           child: CustomScrollView(slivers: <Widget>[ | ||||||
| @@ -157,22 +188,51 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                               style: Theme.of(context).textTheme.headlineMedium, |                               style: Theme.of(context).textTheme.headlineMedium, | ||||||
|                               textAlign: TextAlign.center, |                               textAlign: TextAlign.center, | ||||||
|                             ))), |                             ))), | ||||||
|  |             if (refreshingSince != null) | ||||||
|  |               SliverToBoxAdapter( | ||||||
|  |                 child: LinearProgressIndicator( | ||||||
|  |                   value: appsProvider.apps.values | ||||||
|  |                           .where((element) => !(element.app.lastUpdateCheck | ||||||
|  |                                   ?.isBefore(refreshingSince!) ?? | ||||||
|  |                               true)) | ||||||
|  |                           .length / | ||||||
|  |                       appsProvider.apps.length, | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|             SliverList( |             SliverList( | ||||||
|                 delegate: SliverChildBuilderDelegate( |                 delegate: SliverChildBuilderDelegate( | ||||||
|                     (BuildContext context, int index) { |                     (BuildContext context, int index) { | ||||||
|               return ListTile( |               return ListTile( | ||||||
|                 selectedTileColor: |                 tileColor: sortedApps[index].app.pinned | ||||||
|                     Theme.of(context).colorScheme.primary.withOpacity(0.1), |                     ? Colors.grey.withOpacity(0.1) | ||||||
|                 selected: selectedIds.contains(sortedApps[index].app.id), |                     : Colors.transparent, | ||||||
|  |                 selectedTileColor: Theme.of(context) | ||||||
|  |                     .colorScheme | ||||||
|  |                     .primary | ||||||
|  |                     .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), | ||||||
|  |                 selected: selectedApps.contains(sortedApps[index].app), | ||||||
|                 onLongPress: () { |                 onLongPress: () { | ||||||
|                   toggleAppSelected(sortedApps[index].app.id); |                   toggleAppSelected(sortedApps[index].app); | ||||||
|                 }, |                 }, | ||||||
|                 leading: sortedApps[index].installedInfo != null |                 leading: sortedApps[index].installedInfo != null | ||||||
|                     ? Image.memory(sortedApps[index].installedInfo!.icon!) |                     ? Image.memory( | ||||||
|  |                         sortedApps[index].installedInfo!.icon!, | ||||||
|  |                         gaplessPlayback: true, | ||||||
|  |                       ) | ||||||
|                     : null, |                     : null, | ||||||
|                 title: Text(sortedApps[index].installedInfo?.name ?? |                 title: Text( | ||||||
|                     sortedApps[index].app.name), |                   sortedApps[index].installedInfo?.name ?? | ||||||
|                 subtitle: Text('By ${sortedApps[index].app.author}'), |                       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 |                 trailing: sortedApps[index].downloadProgress != null | ||||||
|                     ? Text( |                     ? Text( | ||||||
|                         'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') |                         'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||||
| @@ -212,11 +272,18 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                       )), |                                       )), | ||||||
|                             ], |                             ], | ||||||
|                           ) |                           ) | ||||||
|                         : Text(sortedApps[index].app.installedVersion ?? |                         : SingleChildScrollView( | ||||||
|                             'Not Installed')), |                             child: SizedBox( | ||||||
|  |                                 width: 80, | ||||||
|  |                                 child: Text( | ||||||
|  |                                   sortedApps[index].app.installedVersion ?? | ||||||
|  |                                       'Not Installed', | ||||||
|  |                                   overflow: TextOverflow.fade, | ||||||
|  |                                   textAlign: TextAlign.end, | ||||||
|  |                                 )))), | ||||||
|                 onTap: () { |                 onTap: () { | ||||||
|                   if (selectedIds.isNotEmpty) { |                   if (selectedApps.isNotEmpty) { | ||||||
|                     toggleAppSelected(sortedApps[index].app.id); |                     toggleAppSelected(sortedApps[index].app); | ||||||
|                   } else { |                   } else { | ||||||
|                     Navigator.push( |                     Navigator.push( | ||||||
|                       context, |                       context, | ||||||
| @@ -234,25 +301,25 @@ class AppsPageState extends State<AppsPage> { | |||||||
|           children: [ |           children: [ | ||||||
|             IconButton( |             IconButton( | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                   selectedIds.isEmpty |                   selectedApps.isEmpty | ||||||
|                       ? selectThese(sortedApps.map((e) => e.app.id).toList()) |                       ? selectThese(sortedApps.map((e) => e.app).toList()) | ||||||
|                       : clearSelected(); |                       : clearSelected(); | ||||||
|                 }, |                 }, | ||||||
|                 icon: Icon( |                 icon: Icon( | ||||||
|                   selectedIds.isEmpty |                   selectedApps.isEmpty | ||||||
|                       ? Icons.select_all_outlined |                       ? Icons.select_all_outlined | ||||||
|                       : Icons.deselect_outlined, |                       : Icons.deselect_outlined, | ||||||
|                   color: Theme.of(context).colorScheme.primary, |                   color: Theme.of(context).colorScheme.primary, | ||||||
|                 ), |                 ), | ||||||
|                 tooltip: selectedIds.isEmpty |                 tooltip: selectedApps.isEmpty | ||||||
|                     ? 'Select All' |                     ? 'Select All' | ||||||
|                     : 'Deselect ${selectedIds.length.toString()}'), |                     : 'Deselect ${selectedApps.length.toString()}'), | ||||||
|             const VerticalDivider(), |             const VerticalDivider(), | ||||||
|             Expanded( |             Expanded( | ||||||
|                 child: Row( |                 child: Row( | ||||||
|               mainAxisAlignment: MainAxisAlignment.spaceEvenly, |               mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|               children: [ |               children: [ | ||||||
|                 selectedIds.isEmpty |                 selectedApps.isEmpty | ||||||
|                     ? const SizedBox() |                     ? const SizedBox() | ||||||
|                     : IconButton( |                     : IconButton( | ||||||
|                         visualDensity: VisualDensity.compact, |                         visualDensity: VisualDensity.compact, | ||||||
| @@ -266,11 +333,12 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                   defaultValues: const [], |                                   defaultValues: const [], | ||||||
|                                   initValid: true, |                                   initValid: true, | ||||||
|                                   message: |                                   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) { |                               }).then((values) { | ||||||
|                             if (values != null) { |                             if (values != null) { | ||||||
|                               appsProvider.removeApps(selectedIds.toList()); |                               appsProvider.removeApps( | ||||||
|  |                                   selectedApps.map((e) => e.id).toList()); | ||||||
|                             } |                             } | ||||||
|                           }); |                           }); | ||||||
|                         }, |                         }, | ||||||
| @@ -306,19 +374,22 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                 builder: (BuildContext ctx) { |                                 builder: (BuildContext ctx) { | ||||||
|                                   return GeneratedFormModal( |                                   return GeneratedFormModal( | ||||||
|                                     title: |                                     title: | ||||||
|                                         'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?', |                                         'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?', | ||||||
|                                     message: |                                     message: | ||||||
|                                         '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', |                                         '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', | ||||||
|                                     items: formInputs, |                                     items: formInputs, | ||||||
|                                     defaultValues: const ['true'], |                                     defaultValues: [ | ||||||
|  |                                       'true', | ||||||
|  |                                       existingUpdateIdsAllOrSelected.isEmpty | ||||||
|  |                                           ? 'true' | ||||||
|  |                                           : '' | ||||||
|  |                                     ], | ||||||
|                                     initValid: true, |                                     initValid: true, | ||||||
|                                   ); |                                   ); | ||||||
|                                 }).then((values) { |                                 }).then((values) { | ||||||
|                               if (values != null) { |                               if (values != null) { | ||||||
|                                 bool shouldInstallUpdates = |                                 bool shouldInstallUpdates = values[0] == 'true'; | ||||||
|                                     values.length < 2 || values[0] == 'true'; |                                 bool shouldInstallNew = values[1] == 'true'; | ||||||
|                                 bool shouldInstallNew = |  | ||||||
|                                     values.length >= 2 && values[1] == 'true'; |  | ||||||
|                                 settingsProvider |                                 settingsProvider | ||||||
|                                     .getInstallPermission() |                                     .getInstallPermission() | ||||||
|                                     .then((_) { |                                     .then((_) { | ||||||
| @@ -335,20 +406,18 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                       .downloadAndInstallLatestApps( |                                       .downloadAndInstallLatestApps( | ||||||
|                                           toInstall, context) |                                           toInstall, context) | ||||||
|                                       .catchError((e) { |                                       .catchError((e) { | ||||||
|                                     ScaffoldMessenger.of(context).showSnackBar( |                                     showError(e, context); | ||||||
|                                       SnackBar(content: Text(e.toString())), |  | ||||||
|                                     ); |  | ||||||
|                                   }); |                                   }); | ||||||
|                                 }); |                                 }); | ||||||
|                               } |                               } | ||||||
|                             }); |                             }); | ||||||
|                           }, |                           }, | ||||||
|                     tooltip: |                     tooltip: | ||||||
|                         'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps', |                         'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps', | ||||||
|                     icon: const Icon( |                     icon: const Icon( | ||||||
|                       Icons.file_download_outlined, |                       Icons.file_download_outlined, | ||||||
|                     )), |                     )), | ||||||
|                 selectedIds.isEmpty |                 selectedApps.isEmpty | ||||||
|                     ? const SizedBox() |                     ? const SizedBox() | ||||||
|                     : IconButton( |                     : IconButton( | ||||||
|                         visualDensity: VisualDensity.compact, |                         visualDensity: VisualDensity.compact, | ||||||
| @@ -377,7 +446,7 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                                                       ctx) { |                                                                       ctx) { | ||||||
|                                                                 return AlertDialog( |                                                                 return AlertDialog( | ||||||
|                                                                   title: Text( |                                                                   title: Text( | ||||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Updated?'), |                                                                       'Mark ${selectedApps.length} Selected Apps as Updated?'), | ||||||
|                                                                   content: |                                                                   content: | ||||||
|                                                                       const Text( |                                                                       const Text( | ||||||
|                                                                           'Only applies to installed but out of date Apps.'), |                                                                           'Only applies to installed but out of date Apps.'), | ||||||
| @@ -396,9 +465,7 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                                                           HapticFeedback |                                                                           HapticFeedback | ||||||
|                                                                               .selectionClick(); |                                                                               .selectionClick(); | ||||||
|                                                                           appsProvider |                                                                           appsProvider | ||||||
|                                                                               .saveApps(selectedIds.map((e) { |                                                                               .saveApps(selectedApps.map((a) { | ||||||
|                                                                             var a = |  | ||||||
|                                                                                 appsProvider.apps[e]!.app; |  | ||||||
|                                                                             if (a.installedVersion != |                                                                             if (a.installedVersion != | ||||||
|                                                                                 null) { |                                                                                 null) { | ||||||
|                                                                               a.installedVersion = a.latestVersion; |                                                                               a.installedVersion = a.latestVersion; | ||||||
| @@ -413,27 +480,84 @@ class AppsPageState extends State<AppsPage> { | |||||||
|                                                                             'Yes')) |                                                                             'Yes')) | ||||||
|                                                                   ], |                                                                   ], | ||||||
|                                                                 ); |                                                                 ); | ||||||
|                                                               }); |                                                               }).whenComplete(() { | ||||||
|  |                                                             Navigator.of( | ||||||
|  |                                                                     context) | ||||||
|  |                                                                 .pop(); | ||||||
|  |                                                           }); | ||||||
|                                                         }, |                                                         }, | ||||||
|                                               tooltip: |                                               tooltip: | ||||||
|                                                   'Mark Selected Apps as Updated', |                                                   'Mark Selected Apps as Updated', | ||||||
|                                               icon: const Icon(Icons.done)), |                                               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( |                                           IconButton( | ||||||
|                                             onPressed: () { |                                             onPressed: () { | ||||||
|                                               String urls = ''; |                                               String urls = ''; | ||||||
|                                               for (var id in selectedIds) { |                                               for (var a in selectedApps) { | ||||||
|                                                 urls += |                                                 urls += '${a.url}\n'; | ||||||
|                                                     '${appsProvider.apps[id]!.app.url}\n'; |  | ||||||
|                                               } |                                               } | ||||||
|                                               urls = urls.substring( |                                               urls = urls.substring( | ||||||
|                                                   0, urls.length - 1); |                                                   0, urls.length - 1); | ||||||
|                                               Share.share(urls, |                                               Share.share(urls, | ||||||
|                                                   subject: |                                                   subject: | ||||||
|                                                       '${selectedIds.length} Selected App URLs from Obtainium'); |                                                       '${selectedApps.length} Selected App URLs from Obtainium'); | ||||||
|  |                                               Navigator.of(context).pop(); | ||||||
|                                             }, |                                             }, | ||||||
|                                             tooltip: 'Share Selected App URLs', |                                             tooltip: 'Share Selected App URLs', | ||||||
|                                             icon: const Icon(Icons.share), |                                             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), | ||||||
|  |                                           ), | ||||||
|                                         ]), |                                         ]), | ||||||
|                                   ), |                                   ), | ||||||
|                                 ); |                                 ); | ||||||
|   | |||||||
| @@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> { | |||||||
|           return !(pages[0].widget.key as GlobalKey<AppsPageState>) |           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||||
|               .currentState |               .currentState | ||||||
|               ?.clearSelected(); |               ?.clearSelected(); | ||||||
|           // return !appsPageKey.currentState?.clearSelected(); |  | ||||||
|         }); |         }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,11 +6,13 @@ import 'package:flutter/services.dart'; | |||||||
| import 'package:obtainium/components/custom_app_bar.dart'; | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
| import 'package:obtainium/components/generated_form_modal.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/apps_provider.dart'; | ||||||
| import 'package:obtainium/providers/settings_provider.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:file_picker/file_picker.dart'; | import 'package:file_picker/file_picker.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class ImportExportPage extends StatefulWidget { | class ImportExportPage extends StatefulWidget { | ||||||
|   const ImportExportPage({super.key}); |   const ImportExportPage({super.key}); | ||||||
| @@ -61,7 +63,6 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|         body: CustomScrollView(slivers: <Widget>[ |         body: CustomScrollView(slivers: <Widget>[ | ||||||
|           const CustomAppBar(title: 'Import/Export'), |           const CustomAppBar(title: 'Import/Export'), | ||||||
|           SliverFillRemaining( |           SliverFillRemaining( | ||||||
|               hasScrollBody: false, |  | ||||||
|               child: Padding( |               child: Padding( | ||||||
|                   padding: |                   padding: | ||||||
|                       const EdgeInsets.symmetric(vertical: 8, horizontal: 16), |                       const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||||
| @@ -81,12 +82,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                           appsProvider |                                           appsProvider | ||||||
|                                               .exportApps() |                                               .exportApps() | ||||||
|                                               .then((String path) { |                                               .then((String path) { | ||||||
|                                             ScaffoldMessenger.of(context) |                                             showError( | ||||||
|                                                 .showSnackBar( |                                                 'Exported to $path', context); | ||||||
|                                               SnackBar( |  | ||||||
|                                                   content: Text( |  | ||||||
|                                                       'Exported to $path')), |  | ||||||
|                                             ); |  | ||||||
|                                           }); |                                           }); | ||||||
|                                         }, |                                         }, | ||||||
|                                   child: const Text('Obtainium Export'))), |                                   child: const Text('Obtainium Export'))), | ||||||
| @@ -113,27 +110,21 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                               try { |                                               try { | ||||||
|                                                 jsonDecode(data); |                                                 jsonDecode(data); | ||||||
|                                               } catch (e) { |                                               } catch (e) { | ||||||
|                                                 throw 'Invalid input'; |                                                 throw ObtainiumError( | ||||||
|  |                                                     'Invalid input'); | ||||||
|                                               } |                                               } | ||||||
|                                               appsProvider |                                               appsProvider | ||||||
|                                                   .importApps(data) |                                                   .importApps(data) | ||||||
|                                                   .then((value) { |                                                   .then((value) { | ||||||
|                                                 ScaffoldMessenger.of(context) |                                                 showError( | ||||||
|                                                     .showSnackBar( |                                                     '$value App${value == 1 ? '' : 's'} Imported', | ||||||
|                                                   SnackBar( |                                                     context); | ||||||
|                                                       content: Text( |  | ||||||
|                                                           '$value App${value == 1 ? '' : 's'} Imported')), |  | ||||||
|                                                 ); |  | ||||||
|                                               }); |                                               }); | ||||||
|                                             } else { |                                             } else { | ||||||
|                                               // User canceled the picker |                                               // User canceled the picker | ||||||
|                                             } |                                             } | ||||||
|                                           }).catchError((e) { |                                           }).catchError((e) { | ||||||
|                                             ScaffoldMessenger.of(context) |                                             showError(e, context); | ||||||
|                                                 .showSnackBar( |  | ||||||
|                                               SnackBar( |  | ||||||
|                                                   content: Text(e.toString())), |  | ||||||
|                                             ); |  | ||||||
|                                           }).whenComplete(() { |                                           }).whenComplete(() { | ||||||
|                                             setState(() { |                                             setState(() { | ||||||
|                                               importInProgress = false; |                                               importInProgress = false; | ||||||
| @@ -208,12 +199,9 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                       }); |                                       }); | ||||||
|                                       addApps(urls).then((errors) { |                                       addApps(urls).then((errors) { | ||||||
|                                         if (errors.isEmpty) { |                                         if (errors.isEmpty) { | ||||||
|                                           ScaffoldMessenger.of(context) |                                           showError( | ||||||
|                                               .showSnackBar( |                                               'Imported ${urls.length} Apps', | ||||||
|                                             SnackBar( |                                               context); | ||||||
|                                                 content: Text( |  | ||||||
|                                                     'Imported ${urls.length} Apps')), |  | ||||||
|                                           ); |  | ||||||
|                                         } else { |                                         } else { | ||||||
|                                           showDialog( |                                           showDialog( | ||||||
|                                               context: context, |                                               context: context, | ||||||
| @@ -224,10 +212,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                               }); |                                               }); | ||||||
|                                         } |                                         } | ||||||
|                                       }).catchError((e) { |                                       }).catchError((e) { | ||||||
|                                         ScaffoldMessenger.of(context) |                                         showError(e, context); | ||||||
|                                             .showSnackBar( |  | ||||||
|                                           SnackBar(content: Text(e.toString())), |  | ||||||
|                                         ); |  | ||||||
|                                       }).whenComplete(() { |                                       }).whenComplete(() { | ||||||
|                                         setState(() { |                                         setState(() { | ||||||
|                                           importInProgress = false; |                                           importInProgress = false; | ||||||
| @@ -239,7 +224,8 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                           child: const Text( |                           child: const Text( | ||||||
|                             'Import from URL List', |                             'Import from URL List', | ||||||
|                           )), |                           )), | ||||||
|                       ...sourceProvider.massSources |                       ...sourceProvider.sources | ||||||
|  |                           .where((element) => element.canSearch) | ||||||
|                           .map((source) => Column( |                           .map((source) => Column( | ||||||
|                                   crossAxisAlignment: |                                   crossAxisAlignment: | ||||||
|                                       CrossAxisAlignment.stretch, |                                       CrossAxisAlignment.stretch, | ||||||
| @@ -249,99 +235,192 @@ class _ImportExportPageState extends State<ImportExportPage> { | |||||||
|                                         onPressed: importInProgress |                                         onPressed: importInProgress | ||||||
|                                             ? null |                                             ? null | ||||||
|                                             : () { |                                             : () { | ||||||
|                                                 showDialog( |                                                 () async { | ||||||
|                                                     context: context, |                                                   var values = await showDialog< | ||||||
|                                                     builder: |                                                           List<String>>( | ||||||
|                                                         (BuildContext ctx) { |                                                       context: context, | ||||||
|                                                       return GeneratedFormModal( |                                                       builder: | ||||||
|                                                         title: |                                                           (BuildContext ctx) { | ||||||
|                                                             'Import ${source.name}', |                                                         return GeneratedFormModal( | ||||||
|                                                         items: source |                                                           title: | ||||||
|                                                             .requiredArgs |                                                               'Search ${source.runtimeType}', | ||||||
|                                                             .map((e) => [ |                                                           items: [ | ||||||
|                                                                   GeneratedFormItem( |                                                             [ | ||||||
|                                                                       label: e) |                                                               GeneratedFormItem( | ||||||
|                                                                 ]) |                                                                   label: | ||||||
|                                                             .toList(), |                                                                       '${source.runtimeType} Search Query') | ||||||
|                                                         defaultValues: const [], |                                                             ] | ||||||
|                                                       ); |                                                           ], | ||||||
|                                                     }).then((values) { |                                                           defaultValues: const [], | ||||||
|                                                   if (values != null) { |                                                         ); | ||||||
|  |                                                       }); | ||||||
|  |                                                   if (values != null && | ||||||
|  |                                                       values[0].isNotEmpty) { | ||||||
|                                                     setState(() { |                                                     setState(() { | ||||||
|                                                       importInProgress = true; |                                                       importInProgress = true; | ||||||
|                                                     }); |                                                     }); | ||||||
|                                                     source |                                                     var urlsWithDescriptions = | ||||||
|                                                         .getUrls(values) |                                                         await source | ||||||
|                                                         .then((urls) { |                                                             .search(values[0]); | ||||||
|                                                       showDialog<List<String>?>( |                                                     if (urlsWithDescriptions | ||||||
|  |                                                         .isNotEmpty) { | ||||||
|  |                                                       var selectedUrls = | ||||||
|  |                                                           await showDialog< | ||||||
|  |                                                                   List< | ||||||
|  |                                                                       String>?>( | ||||||
|                                                               context: context, |                                                               context: context, | ||||||
|                                                               builder: |                                                               builder: | ||||||
|                                                                   (BuildContext |                                                                   (BuildContext | ||||||
|                                                                       ctx) { |                                                                       ctx) { | ||||||
|                                                                 return UrlSelectionModal( |                                                                 return UrlSelectionModal( | ||||||
|                                                                     urls: urls); |                                                                   urlsWithDescriptions: | ||||||
|                                                               }) |                                                                       urlsWithDescriptions, | ||||||
|                                                           .then((selectedUrls) { |                                                                   defaultSelected: | ||||||
|                                                         if (selectedUrls != |                                                                       false, | ||||||
|                                                             null) { |                                                                 ); | ||||||
|                                                           addApps(selectedUrls) |                                                               }); | ||||||
|                                                               .then((errors) { |                                                       if (selectedUrls != | ||||||
|                                                             if (errors |                                                               null && | ||||||
|                                                                 .isEmpty) { |                                                           selectedUrls | ||||||
|                                                               ScaffoldMessenger |                                                               .isNotEmpty) { | ||||||
|                                                                       .of(context) |                                                         var errors = | ||||||
|                                                                   .showSnackBar( |                                                             await addApps( | ||||||
|                                                                 SnackBar( |                                                                 selectedUrls); | ||||||
|                                                                     content: Text( |                                                         if (errors.isEmpty) { | ||||||
|                                                                         'Imported ${selectedUrls.length} Apps')), |                                                           // ignore: use_build_context_synchronously | ||||||
|                                                               ); |                                                           showError( | ||||||
|                                                             } else { |                                                               'Imported ${selectedUrls.length} Apps', | ||||||
|                                                               showDialog( |                                                               context); | ||||||
|                                                                   context: |  | ||||||
|                                                                       context, |  | ||||||
|                                                                   builder: |  | ||||||
|                                                                       (BuildContext |  | ||||||
|                                                                           ctx) { |  | ||||||
|                                                                     return ImportErrorDialog( |  | ||||||
|                                                                         urlsLength: |  | ||||||
|                                                                             selectedUrls |  | ||||||
|                                                                                 .length, |  | ||||||
|                                                                         errors: |  | ||||||
|                                                                             errors); |  | ||||||
|                                                                   }); |  | ||||||
|                                                             } |  | ||||||
|                                                           }).whenComplete(() { |  | ||||||
|                                                             setState(() { |  | ||||||
|                                                               importInProgress = |  | ||||||
|                                                                   false; |  | ||||||
|                                                             }); |  | ||||||
|                                                           }); |  | ||||||
|                                                         } else { |                                                         } else { | ||||||
|                                                           setState(() { |                                                           showDialog( | ||||||
|                                                             importInProgress = |                                                               context: context, | ||||||
|                                                                 false; |                                                               builder: | ||||||
|                                                           }); |                                                                   (BuildContext | ||||||
|  |                                                                       ctx) { | ||||||
|  |                                                                 return ImportErrorDialog( | ||||||
|  |                                                                     urlsLength: | ||||||
|  |                                                                         selectedUrls | ||||||
|  |                                                                             .length, | ||||||
|  |                                                                     errors: | ||||||
|  |                                                                         errors); | ||||||
|  |                                                               }); | ||||||
|                                                         } |                                                         } | ||||||
|                                                       }); |                                                       } | ||||||
|                                                     }).catchError((e) { |                                                     } else { | ||||||
|                                                       setState(() { |                                                       throw ObtainiumError( | ||||||
|                                                         importInProgress = |                                                           'No results found'); | ||||||
|                                                             false; |                                                     } | ||||||
|                                                       }); |  | ||||||
|                                                       ScaffoldMessenger.of( |  | ||||||
|                                                               context) |  | ||||||
|                                                           .showSnackBar( |  | ||||||
|                                                         SnackBar( |  | ||||||
|                                                             content: Text( |  | ||||||
|                                                                 e.toString())), |  | ||||||
|                                                       ); |  | ||||||
|                                                     }); |  | ||||||
|                                                   } |                                                   } | ||||||
|  |                                                 }() | ||||||
|  |                                                     .catchError((e) { | ||||||
|  |                                                   showError(e, context); | ||||||
|  |                                                 }).whenComplete(() { | ||||||
|  |                                                   setState(() { | ||||||
|  |                                                     importInProgress = false; | ||||||
|  |                                                   }); | ||||||
|  |                                                 }); | ||||||
|  |                                               }, | ||||||
|  |                                         child: Text( | ||||||
|  |                                             'Search ${source.runtimeType}')) | ||||||
|  |                                   ])) | ||||||
|  |                           .toList(), | ||||||
|  |                       ...sourceProvider.massUrlSources | ||||||
|  |                           .map((source) => Column( | ||||||
|  |                                   crossAxisAlignment: | ||||||
|  |                                       CrossAxisAlignment.stretch, | ||||||
|  |                                   children: [ | ||||||
|  |                                     const SizedBox(height: 8), | ||||||
|  |                                     TextButton( | ||||||
|  |                                         onPressed: importInProgress | ||||||
|  |                                             ? null | ||||||
|  |                                             : () { | ||||||
|  |                                                 () 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; | ||||||
|  |                                                     }); | ||||||
|  |                                                     var urlsWithDescriptions = | ||||||
|  |                                                         await source | ||||||
|  |                                                             .getUrlsWithDescriptions( | ||||||
|  |                                                                 values); | ||||||
|  |                                                     var selectedUrls = | ||||||
|  |                                                         await showDialog< | ||||||
|  |                                                                 List<String>?>( | ||||||
|  |                                                             context: context, | ||||||
|  |                                                             builder: | ||||||
|  |                                                                 (BuildContext | ||||||
|  |                                                                     ctx) { | ||||||
|  |                                                               return UrlSelectionModal( | ||||||
|  |                                                                   urlsWithDescriptions: | ||||||
|  |                                                                       urlsWithDescriptions); | ||||||
|  |                                                             }); | ||||||
|  |                                                     if (selectedUrls != null) { | ||||||
|  |                                                       var errors = | ||||||
|  |                                                           await addApps( | ||||||
|  |                                                               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}')) |                                         child: Text('Import ${source.name}')) | ||||||
|                                   ])) |                                   ])) | ||||||
|                           .toList() |                           .toList(), | ||||||
|  |                       const Spacer(), | ||||||
|  |                       const Divider( | ||||||
|  |                         height: 32, | ||||||
|  |                       ), | ||||||
|  |                       const Text( | ||||||
|  |                           'Imported Apps may incorrectly show as "Not Installed".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.', | ||||||
|  |                           textAlign: TextAlign.center, | ||||||
|  |                           style: TextStyle( | ||||||
|  |                               fontStyle: FontStyle.italic, fontSize: 12)), | ||||||
|  |                       const SizedBox( | ||||||
|  |                         height: 8, | ||||||
|  |                       ) | ||||||
|                     ], |                     ], | ||||||
|                   ))) |                   ))) | ||||||
|         ])); |         ])); | ||||||
| @@ -404,21 +483,26 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> { | |||||||
|  |  | ||||||
| // ignore: must_be_immutable | // ignore: must_be_immutable | ||||||
| class UrlSelectionModal extends StatefulWidget { | class UrlSelectionModal extends StatefulWidget { | ||||||
|   UrlSelectionModal({super.key, required this.urls}); |   UrlSelectionModal( | ||||||
|  |       {super.key, | ||||||
|  |       required this.urlsWithDescriptions, | ||||||
|  |       this.defaultSelected = true}); | ||||||
|  |  | ||||||
|   List<String> urls; |   Map<String, String> urlsWithDescriptions; | ||||||
|  |   bool defaultSelected; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); |   State<UrlSelectionModal> createState() => _UrlSelectionModalState(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class _UrlSelectionModalState extends State<UrlSelectionModal> { | class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||||
|   Map<String, bool> urlSelections = {}; |   Map<MapEntry<String, String>, bool> urlWithDescriptionSelections = {}; | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     for (var url in widget.urls) { |     for (var url in widget.urlsWithDescriptions.entries) { | ||||||
|       urlSelections.putIfAbsent(url, () => true); |       urlWithDescriptionSelections.putIfAbsent( | ||||||
|  |           url, () => widget.defaultSelected); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -428,21 +512,48 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | |||||||
|       scrollable: true, |       scrollable: true, | ||||||
|       title: const Text('Select URLs to Import'), |       title: const Text('Select URLs to Import'), | ||||||
|       content: Column(children: [ |       content: Column(children: [ | ||||||
|         ...urlSelections.keys.map((url) { |         ...urlWithDescriptionSelections.keys.map((urlWithD) { | ||||||
|           return Row(children: [ |           return Row(children: [ | ||||||
|             Checkbox( |             Checkbox( | ||||||
|                 value: urlSelections[url], |                 value: urlWithDescriptionSelections[urlWithD], | ||||||
|                 onChanged: (value) { |                 onChanged: (value) { | ||||||
|                   setState(() { |                   setState(() { | ||||||
|                     urlSelections[url] = value ?? false; |                     urlWithDescriptionSelections[urlWithD] = value ?? false; | ||||||
|                   }); |                   }); | ||||||
|                 }), |                 }), | ||||||
|             const SizedBox( |             const SizedBox( | ||||||
|               width: 8, |               width: 8, | ||||||
|             ), |             ), | ||||||
|             Expanded( |             Expanded( | ||||||
|                 child: Text( |                 child: Column( | ||||||
|               Uri.parse(url).path.substring(1), |               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, | ||||||
|  |                 ) | ||||||
|  |               ], | ||||||
|             )) |             )) | ||||||
|           ]); |           ]); | ||||||
|         }) |         }) | ||||||
| @@ -455,12 +566,13 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | |||||||
|             child: const Text('Cancel')), |             child: const Text('Cancel')), | ||||||
|         TextButton( |         TextButton( | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               Navigator.of(context).pop(urlSelections.keys |               Navigator.of(context).pop(urlWithDescriptionSelections.entries | ||||||
|                   .where((url) => urlSelections[url] ?? false) |                   .where((entry) => entry.value) | ||||||
|  |                   .map((e) => e.key.key) | ||||||
|                   .toList()); |                   .toList()); | ||||||
|             }, |             }, | ||||||
|             child: Text( |             child: Text( | ||||||
|                 'Import ${urlSelections.values.where((b) => b).length} URLs')) |                 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,9 +1,13 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:obtainium/components/custom_app_bar.dart'; | import 'package:obtainium/components/custom_app_bar.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form_modal.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/settings_provider.dart'; | ||||||
| import 'package:obtainium/providers/source_provider.dart'; | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:share_plus/share_plus.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class SettingsPage extends StatefulWidget { | class SettingsPage extends StatefulWidget { | ||||||
| @@ -21,6 +25,143 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     var themeDropdown = DropdownButtonFormField( | ||||||
|  |         decoration: const InputDecoration(labelText: 'Theme'), | ||||||
|  |         value: settingsProvider.theme, | ||||||
|  |         items: const [ | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: ThemeSettings.dark, | ||||||
|  |             child: Text('Dark'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: ThemeSettings.light, | ||||||
|  |             child: Text('Light'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: ThemeSettings.system, | ||||||
|  |             child: Text('Follow System'), | ||||||
|  |           ) | ||||||
|  |         ], | ||||||
|  |         onChanged: (value) { | ||||||
|  |           if (value != null) { | ||||||
|  |             settingsProvider.theme = value; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     var colourDropdown = DropdownButtonFormField( | ||||||
|  |         decoration: const InputDecoration(labelText: 'Colour'), | ||||||
|  |         value: settingsProvider.colour, | ||||||
|  |         items: const [ | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: ColourSettings.basic, | ||||||
|  |             child: Text('Obtainium'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: ColourSettings.materialYou, | ||||||
|  |             child: Text('Material You'), | ||||||
|  |           ) | ||||||
|  |         ], | ||||||
|  |         onChanged: (value) { | ||||||
|  |           if (value != null) { | ||||||
|  |             settingsProvider.colour = value; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     var sortDropdown = DropdownButtonFormField( | ||||||
|  |         decoration: const InputDecoration(labelText: 'App Sort By'), | ||||||
|  |         value: settingsProvider.sortColumn, | ||||||
|  |         items: const [ | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: SortColumnSettings.authorName, | ||||||
|  |             child: Text('Author/Name'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: SortColumnSettings.nameAuthor, | ||||||
|  |             child: Text('Name/Author'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: SortColumnSettings.added, | ||||||
|  |             child: Text('As Added'), | ||||||
|  |           ) | ||||||
|  |         ], | ||||||
|  |         onChanged: (value) { | ||||||
|  |           if (value != null) { | ||||||
|  |             settingsProvider.sortColumn = value; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     var orderDropdown = DropdownButtonFormField( | ||||||
|  |         decoration: const InputDecoration(labelText: 'App Sort Order'), | ||||||
|  |         value: settingsProvider.sortOrder, | ||||||
|  |         items: const [ | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: SortOrderSettings.ascending, | ||||||
|  |             child: Text('Ascending'), | ||||||
|  |           ), | ||||||
|  |           DropdownMenuItem( | ||||||
|  |             value: SortOrderSettings.descending, | ||||||
|  |             child: Text('Descending'), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |         onChanged: (value) { | ||||||
|  |           if (value != null) { | ||||||
|  |             settingsProvider.sortOrder = value; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     var intervalDropdown = DropdownButtonFormField( | ||||||
|  |         decoration: const InputDecoration( | ||||||
|  |             labelText: 'Background Update Checking Interval'), | ||||||
|  |         value: settingsProvider.updateInterval, | ||||||
|  |         items: updateIntervals.map((e) { | ||||||
|  |           int displayNum = (e < 60 | ||||||
|  |                   ? e | ||||||
|  |                   : e < 1440 | ||||||
|  |                       ? e / 60 | ||||||
|  |                       : e / 1440) | ||||||
|  |               .round(); | ||||||
|  |           var displayUnit = (e < 60 | ||||||
|  |               ? 'Minute' | ||||||
|  |               : e < 1440 | ||||||
|  |                   ? 'Hour' | ||||||
|  |                   : 'Day'); | ||||||
|  |  | ||||||
|  |           String display = e == 0 | ||||||
|  |               ? 'Never - Manual Only' | ||||||
|  |               : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; | ||||||
|  |           return DropdownMenuItem(value: e, child: Text(display)); | ||||||
|  |         }).toList(), | ||||||
|  |         onChanged: (value) { | ||||||
|  |           if (value != null) { | ||||||
|  |             settingsProvider.updateInterval = value; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |     var sourceSpecificFields = sourceProvider.sources.map((e) { | ||||||
|  |       if (e.moreSourceSettingsFormItems.isNotEmpty) { | ||||||
|  |         return GeneratedForm( | ||||||
|  |             items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), | ||||||
|  |             onValueChanges: (values, valid, isBuilding) { | ||||||
|  |               if (valid) { | ||||||
|  |                 for (var i = 0; i < values.length; i++) { | ||||||
|  |                   settingsProvider.setSettingString( | ||||||
|  |                       e.moreSourceSettingsFormItems[i].id, values[i]); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |             }, | ||||||
|  |             defaultValues: e.moreSourceSettingsFormItems.map((e) { | ||||||
|  |               return settingsProvider.getSettingString(e.id) ?? ''; | ||||||
|  |             }).toList()); | ||||||
|  |       } else { | ||||||
|  |         return Container(); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const height16 = SizedBox( | ||||||
|  |       height: 16, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|         backgroundColor: Theme.of(context).colorScheme.surface, |         backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|         body: CustomScrollView(slivers: <Widget>[ |         body: CustomScrollView(slivers: <Widget>[ | ||||||
| @@ -38,112 +179,22 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                               style: TextStyle( |                               style: TextStyle( | ||||||
|                                   color: Theme.of(context).colorScheme.primary), |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|                             ), |                             ), | ||||||
|                             DropdownButtonFormField( |                             themeDropdown, | ||||||
|                                 decoration: |                             height16, | ||||||
|                                     const InputDecoration(labelText: 'Theme'), |                             colourDropdown, | ||||||
|                                 value: settingsProvider.theme, |                             height16, | ||||||
|                                 items: const [ |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: ThemeSettings.dark, |  | ||||||
|                                     child: Text('Dark'), |  | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: ThemeSettings.light, |  | ||||||
|                                     child: Text('Light'), |  | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: ThemeSettings.system, |  | ||||||
|                                     child: Text('Follow System'), |  | ||||||
|                                   ) |  | ||||||
|                                 ], |  | ||||||
|                                 onChanged: (value) { |  | ||||||
|                                   if (value != null) { |  | ||||||
|                                     settingsProvider.theme = value; |  | ||||||
|                                   } |  | ||||||
|                                 }), |  | ||||||
|                             const SizedBox( |  | ||||||
|                               height: 16, |  | ||||||
|                             ), |  | ||||||
|                             DropdownButtonFormField( |  | ||||||
|                                 decoration: |  | ||||||
|                                     const InputDecoration(labelText: 'Colour'), |  | ||||||
|                                 value: settingsProvider.colour, |  | ||||||
|                                 items: const [ |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: ColourSettings.basic, |  | ||||||
|                                     child: Text('Obtainium'), |  | ||||||
|                                   ), |  | ||||||
|                                   DropdownMenuItem( |  | ||||||
|                                     value: ColourSettings.materialYou, |  | ||||||
|                                     child: Text('Material You'), |  | ||||||
|                                   ) |  | ||||||
|                                 ], |  | ||||||
|                                 onChanged: (value) { |  | ||||||
|                                   if (value != null) { |  | ||||||
|                                     settingsProvider.colour = value; |  | ||||||
|                                   } |  | ||||||
|                                 }), |  | ||||||
|                             const SizedBox( |  | ||||||
|                               height: 16, |  | ||||||
|                             ), |  | ||||||
|                             Row( |                             Row( | ||||||
|                               mainAxisAlignment: MainAxisAlignment.start, |                               mainAxisAlignment: MainAxisAlignment.start, | ||||||
|                               crossAxisAlignment: CrossAxisAlignment.start, |                               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                               children: [ |                               children: [ | ||||||
|                                 Expanded( |                                 Expanded(child: sortDropdown), | ||||||
|                                     child: DropdownButtonFormField( |  | ||||||
|                                         decoration: const InputDecoration( |  | ||||||
|                                             labelText: 'App Sort By'), |  | ||||||
|                                         value: settingsProvider.sortColumn, |  | ||||||
|                                         items: const [ |  | ||||||
|                                           DropdownMenuItem( |  | ||||||
|                                             value: |  | ||||||
|                                                 SortColumnSettings.authorName, |  | ||||||
|                                             child: Text('Author/Name'), |  | ||||||
|                                           ), |  | ||||||
|                                           DropdownMenuItem( |  | ||||||
|                                             value: |  | ||||||
|                                                 SortColumnSettings.nameAuthor, |  | ||||||
|                                             child: Text('Name/Author'), |  | ||||||
|                                           ), |  | ||||||
|                                           DropdownMenuItem( |  | ||||||
|                                             value: SortColumnSettings.added, |  | ||||||
|                                             child: Text('As Added'), |  | ||||||
|                                           ) |  | ||||||
|                                         ], |  | ||||||
|                                         onChanged: (value) { |  | ||||||
|                                           if (value != null) { |  | ||||||
|                                             settingsProvider.sortColumn = value; |  | ||||||
|                                           } |  | ||||||
|                                         })), |  | ||||||
|                                 const SizedBox( |                                 const SizedBox( | ||||||
|                                   width: 16, |                                   width: 16, | ||||||
|                                 ), |                                 ), | ||||||
|                                 Expanded( |                                 Expanded(child: orderDropdown), | ||||||
|                                     child: DropdownButtonFormField( |  | ||||||
|                                         decoration: const InputDecoration( |  | ||||||
|                                             labelText: 'App Sort Order'), |  | ||||||
|                                         value: settingsProvider.sortOrder, |  | ||||||
|                                         items: const [ |  | ||||||
|                                           DropdownMenuItem( |  | ||||||
|                                             value: SortOrderSettings.ascending, |  | ||||||
|                                             child: Text('Ascending'), |  | ||||||
|                                           ), |  | ||||||
|                                           DropdownMenuItem( |  | ||||||
|                                             value: SortOrderSettings.descending, |  | ||||||
|                                             child: Text('Descending'), |  | ||||||
|                                           ), |  | ||||||
|                                         ], |  | ||||||
|                                         onChanged: (value) { |  | ||||||
|                                           if (value != null) { |  | ||||||
|                                             settingsProvider.sortOrder = value; |  | ||||||
|                                           } |  | ||||||
|                                         })), |  | ||||||
|                               ], |                               ], | ||||||
|                             ), |                             ), | ||||||
|                             const SizedBox( |                             height16, | ||||||
|                               height: 16, |  | ||||||
|                             ), |  | ||||||
|                             Row( |                             Row( | ||||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, |                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|                               children: [ |                               children: [ | ||||||
| @@ -155,57 +206,28 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                                     }) |                                     }) | ||||||
|                               ], |                               ], | ||||||
|                             ), |                             ), | ||||||
|  |                             height16, | ||||||
|  |                             Row( | ||||||
|  |                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |                               children: [ | ||||||
|  |                                 const Text('Pin Updates to Top of Apps View'), | ||||||
|  |                                 Switch( | ||||||
|  |                                     value: settingsProvider.pinUpdates, | ||||||
|  |                                     onChanged: (value) { | ||||||
|  |                                       settingsProvider.pinUpdates = value; | ||||||
|  |                                     }) | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|                             const Divider( |                             const Divider( | ||||||
|                               height: 16, |                               height: 16, | ||||||
|                             ), |                             ), | ||||||
|                             const SizedBox( |                             height16, | ||||||
|                               height: 16, |  | ||||||
|                             ), |  | ||||||
|                             Text( |                             Text( | ||||||
|                               'Updates', |                               'Updates', | ||||||
|                               style: TextStyle( |                               style: TextStyle( | ||||||
|                                   color: Theme.of(context).colorScheme.primary), |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|                             ), |                             ), | ||||||
|                             DropdownButtonFormField( |                             intervalDropdown, | ||||||
|                                 decoration: const InputDecoration( |  | ||||||
|                                     labelText: |  | ||||||
|                                         'Background Update Checking Interval'), |  | ||||||
|                                 value: settingsProvider.updateInterval, |  | ||||||
|                                 items: updateIntervals.map((e) { |  | ||||||
|                                   int displayNum = (e < 60 |  | ||||||
|                                           ? e |  | ||||||
|                                           : e < 1440 |  | ||||||
|                                               ? e / 60 |  | ||||||
|                                               : e / 1440) |  | ||||||
|                                       .round(); |  | ||||||
|                                   var displayUnit = (e < 60 |  | ||||||
|                                       ? 'Minute' |  | ||||||
|                                       : e < 1440 |  | ||||||
|                                           ? 'Hour' |  | ||||||
|                                           : 'Day'); |  | ||||||
|  |  | ||||||
|                                   String display = e == 0 |  | ||||||
|                                       ? 'Never - Manual Only' |  | ||||||
|                                       : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; |  | ||||||
|                                   return DropdownMenuItem( |  | ||||||
|                                       value: e, child: Text(display)); |  | ||||||
|                                 }).toList(), |  | ||||||
|                                 onChanged: (value) { |  | ||||||
|                                   if (value != null) { |  | ||||||
|                                     settingsProvider.updateInterval = value; |  | ||||||
|                                   } |  | ||||||
|                                 }), |  | ||||||
|                             const SizedBox( |  | ||||||
|                               height: 8, |  | ||||||
|                             ), |  | ||||||
|                             Text( |  | ||||||
|                               'Longer intervals recommended for large App collections', |  | ||||||
|                               style: Theme.of(context) |  | ||||||
|                                   .textTheme |  | ||||||
|                                   .labelMedium! |  | ||||||
|                                   .merge(const TextStyle( |  | ||||||
|                                       fontStyle: FontStyle.italic)), |  | ||||||
|                             ), |  | ||||||
|                             const Divider( |                             const Divider( | ||||||
|                               height: 48, |                               height: 48, | ||||||
|                             ), |                             ), | ||||||
| @@ -214,65 +236,118 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                               style: TextStyle( |                               style: TextStyle( | ||||||
|                                   color: Theme.of(context).colorScheme.primary), |                                   color: Theme.of(context).colorScheme.primary), | ||||||
|                             ), |                             ), | ||||||
|                             ...sourceProvider.sources.map((e) { |                             ...sourceSpecificFields, | ||||||
|                               if (e.moreSourceSettingsFormItems.isNotEmpty) { |  | ||||||
|                                 return GeneratedForm( |  | ||||||
|                                     items: e.moreSourceSettingsFormItems |  | ||||||
|                                         .map((e) => [e]) |  | ||||||
|                                         .toList(), |  | ||||||
|                                     onValueChanges: (values, valid) { |  | ||||||
|                                       if (valid) { |  | ||||||
|                                         for (var i = 0; |  | ||||||
|                                             i < values.length; |  | ||||||
|                                             i++) { |  | ||||||
|                                           settingsProvider.setSettingString( |  | ||||||
|                                               e.moreSourceSettingsFormItems[i] |  | ||||||
|                                                   .id, |  | ||||||
|                                               values[i]); |  | ||||||
|                                         } |  | ||||||
|                                       } |  | ||||||
|                                     }, |  | ||||||
|                                     defaultValues: |  | ||||||
|                                         e.moreSourceSettingsFormItems.map((e) { |  | ||||||
|                                       return settingsProvider |  | ||||||
|                                               .getSettingString(e.id) ?? |  | ||||||
|                                           ''; |  | ||||||
|                                     }).toList()); |  | ||||||
|                               } else { |  | ||||||
|                                 return Container(); |  | ||||||
|                               } |  | ||||||
|                             }), |  | ||||||
|                           ], |                           ], | ||||||
|                         ))), |                         ))), | ||||||
|           SliverToBoxAdapter( |           SliverToBoxAdapter( | ||||||
|             child: Column( |             child: Column( | ||||||
|               children: [ |               children: [ | ||||||
|                 const SizedBox( |                 const Divider( | ||||||
|                   height: 16, |                   height: 32, | ||||||
|                 ), |                 ), | ||||||
|                 TextButton.icon( |                 Row( | ||||||
|                   style: ButtonStyle( |                   mainAxisAlignment: MainAxisAlignment.spaceAround, | ||||||
|                     foregroundColor: MaterialStateProperty.resolveWith<Color>( |                   children: [ | ||||||
|                         (Set<MaterialState> states) { |                     TextButton.icon( | ||||||
|                       return Colors.grey; |                       onPressed: () { | ||||||
|                     }), |                         launchUrlString(settingsProvider.sourceUrl, | ||||||
|                   ), |                             mode: LaunchMode.externalApplication); | ||||||
|                   onPressed: () { |                       }, | ||||||
|                     launchUrlString(settingsProvider.sourceUrl, |                       icon: const Icon(Icons.code), | ||||||
|                         mode: LaunchMode.externalApplication); |                       label: const Text( | ||||||
|                   }, |                         'App Source', | ||||||
|                   icon: const Icon(Icons.code), |                       ), | ||||||
|                   label: Text( |                     ), | ||||||
|                     'Source', |                     TextButton.icon( | ||||||
|                     style: Theme.of(context).textTheme.bodySmall, |                         onPressed: () { | ||||||
|                   ), |                           context.read<LogsProvider>().get().then((logs) { | ||||||
|                 ), |                             if (logs.isEmpty) { | ||||||
|                 const SizedBox( |                               showError(ObtainiumError('No Logs'), context); | ||||||
|                   height: 16, |                             } else { | ||||||
|  |                               showDialog( | ||||||
|  |                                   context: context, | ||||||
|  |                                   builder: (BuildContext ctx) { | ||||||
|  |                                     return const LogsDialog(); | ||||||
|  |                                   }); | ||||||
|  |                             } | ||||||
|  |                           }); | ||||||
|  |                         }, | ||||||
|  |                         icon: const Icon(Icons.bug_report_outlined), | ||||||
|  |                         label: const Text('App Logs')), | ||||||
|  |                   ], | ||||||
|                 ), |                 ), | ||||||
|  |                 height16, | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|           ) |           ) | ||||||
|         ])); |         ])); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | 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')) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -8,12 +8,14 @@ import 'dart:io'; | |||||||
| import 'package:device_info_plus/device_info_plus.dart'; | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:fluttertoast/fluttertoast.dart'; | ||||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||||
| import 'package:installed_apps/app_info.dart'; | import 'package:installed_apps/app_info.dart'; | ||||||
| import 'package:installed_apps/installed_apps.dart'; | import 'package:installed_apps/installed_apps.dart'; | ||||||
| import 'package:obtainium/app_sources/github.dart'; |  | ||||||
| import 'package:obtainium/custom_errors.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/notifications_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:package_archive_info/package_archive_info.dart'; | import 'package:package_archive_info/package_archive_info.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:path_provider/path_provider.dart'; | import 'package:path_provider/path_provider.dart'; | ||||||
| @@ -24,15 +26,15 @@ import 'package:http/http.dart'; | |||||||
| class AppInMemory { | class AppInMemory { | ||||||
|   late App app; |   late App app; | ||||||
|   double? downloadProgress; |   double? downloadProgress; | ||||||
|   AppInfo? installedInfo; // Also indicates that an App is installed |   AppInfo? installedInfo; | ||||||
|  |  | ||||||
|   AppInMemory(this.app, this.downloadProgress, this.installedInfo); |   AppInMemory(this.app, this.downloadProgress, this.installedInfo); | ||||||
| } | } | ||||||
|  |  | ||||||
| class DownloadedApp { | class DownloadedApk { | ||||||
|   String appId; |   String appId; | ||||||
|   File file; |   File file; | ||||||
|   DownloadedApp(this.appId, this.file); |   DownloadedApk(this.appId, this.file); | ||||||
| } | } | ||||||
|  |  | ||||||
| class AppsProvider with ChangeNotifier { | class AppsProvider with ChangeNotifier { | ||||||
| @@ -40,55 +42,54 @@ class AppsProvider with ChangeNotifier { | |||||||
|   Map<String, AppInMemory> apps = {}; |   Map<String, AppInMemory> apps = {}; | ||||||
|   bool loadingApps = false; |   bool loadingApps = false; | ||||||
|   bool gettingUpdates = 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) |   // Variables to keep track of the app foreground status (installs can't run in the background) | ||||||
|   bool isForeground = true; |   bool isForeground = true; | ||||||
|   late Stream<FGBGType>? foregroundStream; |   late Stream<FGBGType>? foregroundStream; | ||||||
|   late StreamSubscription<FGBGType>? foregroundSubscription; |   late StreamSubscription<FGBGType>? foregroundSubscription; | ||||||
|  |  | ||||||
|   AppsProvider( |   AppsProvider({this.forBGTask = false}) { | ||||||
|       {bool shouldLoadApps = false, |     // Many setup tasks should only be done in the foreground isolate | ||||||
|       bool shouldCheckUpdatesAfterLoad = false, |     if (!forBGTask) { | ||||||
|       bool shouldDeleteAPKs = false}) { |  | ||||||
|     if (shouldLoadApps) { |  | ||||||
|       // Subscribe to changes in the app foreground status |       // Subscribe to changes in the app foreground status | ||||||
|       foregroundStream = FGBGEvents.stream.asBroadcastStream(); |       foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||||
|       foregroundSubscription = foregroundStream?.listen((event) async { |       foregroundSubscription = foregroundStream?.listen((event) async { | ||||||
|         isForeground = event == FGBGType.foreground; |         isForeground = event == FGBGType.foreground; | ||||||
|         if (isForeground) await loadApps(); |         if (isForeground) await loadApps(); | ||||||
|       }); |       }); | ||||||
|       loadApps().then((_) { |       () async { | ||||||
|         if (shouldDeleteAPKs) { |         // Load Apps into memory (in background, this is done later instead of in the constructor) | ||||||
|           deleteSavedAPKs(); |         await loadApps(); | ||||||
|         } |         // Delete existing APKs | ||||||
|         if (shouldCheckUpdatesAfterLoad) { |         (await getExternalStorageDirectory()) | ||||||
|           checkUpdates(); |             ?.listSync() | ||||||
|         } |             .where((element) => | ||||||
|       }); |                 element.path.endsWith('.apk') || | ||||||
|  |                 element.path.endsWith('.apk.part')) | ||||||
|  |             .forEach((apk) { | ||||||
|  |           apk.delete(); | ||||||
|  |         }); | ||||||
|  |       }(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   downloadApk(String apkUrl, String fileName, Function? onProgress, |   downloadFile(String url, String fileName, Function? onProgress, | ||||||
|       Function? urlModifier, |       {bool useExisting = true}) async { | ||||||
|       {bool useExistingIfExists = true}) async { |  | ||||||
|     var destDir = (await getExternalStorageDirectory())!.path; |     var destDir = (await getExternalStorageDirectory())!.path; | ||||||
|     if (urlModifier != null) { |  | ||||||
|       apkUrl = await urlModifier(apkUrl); |  | ||||||
|     } |  | ||||||
|     StreamedResponse response = |     StreamedResponse response = | ||||||
|         await Client().send(Request('GET', Uri.parse(apkUrl))); |         await Client().send(Request('GET', Uri.parse(url))); | ||||||
|     File downloadFile = File('$destDir/$fileName.apk'); |     File downloadedFile = File('$destDir/$fileName'); | ||||||
|     var alreadyExists = downloadFile.existsSync(); |     if (!(downloadedFile.existsSync() && useExisting)) { | ||||||
|     if (!alreadyExists || !useExistingIfExists) { |       File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||||
|       if (alreadyExists) { |       if (tempDownloadedFile.existsSync()) { | ||||||
|         downloadFile.deleteSync(); |         tempDownloadedFile.deleteSync(); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       var length = response.contentLength; |       var length = response.contentLength; | ||||||
|       var received = 0; |       var received = 0; | ||||||
|       double? progress; |       double? progress; | ||||||
|       var sink = downloadFile.openWrite(); |       var sink = tempDownloadedFile.openWrite(); | ||||||
|  |  | ||||||
|       await response.stream.map((s) { |       await response.stream.map((s) { | ||||||
|         received += s.length; |         received += s.length; | ||||||
|         progress = (length != null ? received / length * 100 : 30); |         progress = (length != null ? received / length * 100 : 30); | ||||||
| @@ -97,56 +98,65 @@ class AppsProvider with ChangeNotifier { | |||||||
|         } |         } | ||||||
|         return s; |         return s; | ||||||
|       }).pipe(sink); |       }).pipe(sink); | ||||||
|  |  | ||||||
|       await sink.close(); |       await sink.close(); | ||||||
|       progress = null; |       progress = null; | ||||||
|       if (onProgress != null) { |       if (onProgress != null) { | ||||||
|         onProgress(progress); |         onProgress(progress); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (response.statusCode != 200) { |       if (response.statusCode != 200) { | ||||||
|         downloadFile.deleteSync(); |         tempDownloadedFile.deleteSync(); | ||||||
|         throw response.reasonPhrase ?? 'Unknown Error'; |         throw response.reasonPhrase ?? 'Unknown Error'; | ||||||
|       } |       } | ||||||
|  |       tempDownloadedFile.renameSync(downloadedFile.path); | ||||||
|     } |     } | ||||||
|     return downloadFile; |     return downloadedFile; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Downloads the App (preferred URL) and returns an ApkFile object |   Future<DownloadedApk> downloadApp(App app) async { | ||||||
|   // If the app was already saved, updates it's download progress % in memory |     var fileName = | ||||||
|   // But also works for Apps that are not saved |         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; | ||||||
|   Future<DownloadedApp> downloadApp(App app) async { |     String downloadUrl = await SourceProvider() | ||||||
|     var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}'; |         .getSource(app.url) | ||||||
|     File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex], |         .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]); | ||||||
|         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}', |     int? prevProg; | ||||||
|         (double? progress) { |     File downloadedFile = | ||||||
|  |         await downloadFile(downloadUrl, fileName, (double? progress) { | ||||||
|  |       int? prog = progress?.ceil(); | ||||||
|       if (apps[app.id] != null) { |       if (apps[app.id] != null) { | ||||||
|         apps[app.id]!.downloadProgress = progress; |         apps[app.id]!.downloadProgress = progress; | ||||||
|  |         notifyListeners(); | ||||||
|  |       } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) { | ||||||
|  |         Fluttertoast.showToast( | ||||||
|  |             msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT); | ||||||
|       } |       } | ||||||
|       notifyListeners(); |       prevProg = prog; | ||||||
|     }, SourceProvider().getSource(app.url).apkUrlPrefetchModifier); |     }); | ||||||
|     // Delete older versions of the APK if any |     // Delete older versions of the APK if any | ||||||
|     for (var file in downloadFile.parent.listSync()) { |     for (var file in downloadedFile.parent.listSync()) { | ||||||
|       var fn = file.path.split('/').last; |       var fn = file.path.split('/').last; | ||||||
|       if (fn.startsWith('${app.id}-') && |       if (fn.startsWith('${app.id}-') && | ||||||
|           fn.endsWith('.apk') && |           fn.endsWith('.apk') && | ||||||
|           fn != '$fileName.apk') { |           fn != fileName) { | ||||||
|         file.delete(); |         file.delete(); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // If the ID has changed (as it should on first download), replace it |     // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed | ||||||
|     var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path); |     // The former case should be handled (give the App its real ID), the latter is a security issue | ||||||
|  |     var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path); | ||||||
|     if (app.id != newInfo.packageName) { |     if (app.id != newInfo.packageName) { | ||||||
|  |       if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) { | ||||||
|  |         throw IDChangedError(); | ||||||
|  |       } | ||||||
|       var originalAppId = app.id; |       var originalAppId = app.id; | ||||||
|       app.id = newInfo.packageName; |       app.id = newInfo.packageName; | ||||||
|       downloadFile = downloadFile.renameSync( |       downloadedFile = downloadedFile.renameSync( | ||||||
|           '${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); |           '${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); | ||||||
|       if (apps[originalAppId] != null) { |       if (apps[originalAppId] != null) { | ||||||
|         await removeApps([originalAppId]); |         await removeApps([originalAppId]); | ||||||
|         await saveApps([app]); |         await saveApps([app]); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return DownloadedApp(app.id, downloadFile); |     return DownloadedApk(app.id, downloadedFile); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool areDownloadsRunning() => apps.values |   bool areDownloadsRunning() => apps.values | ||||||
| @@ -154,24 +164,35 @@ class AppsProvider with ChangeNotifier { | |||||||
|       .isNotEmpty; |       .isNotEmpty; | ||||||
|  |  | ||||||
|   Future<bool> canInstallSilently(App app) async { |   Future<bool> canInstallSilently(App app) async { | ||||||
|     // TODO: This is unreliable - try to get from OS in the future |     return false; | ||||||
|     var osInfo = await DeviceInfoPlugin().androidInfo; |     // TODO: Uncomment the below once silentupdates are ever figured out | ||||||
|     return app.installedVersion != null && |     // // TODO: This is unreliable - try to get from OS in the future | ||||||
|         osInfo.version.sdkInt >= 30 && |     // if (app.apkUrls.length > 1) { | ||||||
|         osInfo.version.release.compareTo('12') >= 0; |     //    return false; | ||||||
|  |     // } | ||||||
|  |     // var osInfo = await DeviceInfoPlugin().androidInfo; | ||||||
|  |     // return app.installedVersion != null && | ||||||
|  |     //     osInfo.version.sdkInt >= 30 && | ||||||
|  |     //     osInfo.version.release.compareTo('12') >= 0; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> askUserToReturnToForeground(BuildContext context, |   Future<void> waitForUserToReturnToForeground(BuildContext context) async { | ||||||
|       {bool waitForFG = false}) async { |  | ||||||
|     NotificationsProvider notificationsProvider = |     NotificationsProvider notificationsProvider = | ||||||
|         context.read<NotificationsProvider>(); |         context.read<NotificationsProvider>(); | ||||||
|     if (!isForeground) { |     if (!isForeground) { | ||||||
|       await notificationsProvider.notify(completeInstallationNotification, |       await notificationsProvider.notify(completeInstallationNotification, | ||||||
|           cancelExisting: true); |           cancelExisting: true); | ||||||
|       if (waitForFG) { |       while (await FGBGEvents.stream.first != FGBGType.foreground) {} | ||||||
|         await FGBGEvents.stream.first == FGBGType.foreground; |       await notificationsProvider.cancel(completeInstallationNotification.id); | ||||||
|         await notificationsProvider.cancel(completeInstallationNotification.id); |     } | ||||||
|       } |   } | ||||||
|  |  | ||||||
|  |   Future<bool> canDowngradeApps() async { | ||||||
|  |     try { | ||||||
|  |       await InstalledApps.getAppInfo('com.berdik.letmedowngrade'); | ||||||
|  |       return true; | ||||||
|  |     } catch (e) { | ||||||
|  |       return false; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -179,7 +200,7 @@ class AppsProvider with ChangeNotifier { | |||||||
|   // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing |   // 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 |   // If appropriate criteria are met, the update (never a fresh install) happens silently  in the background | ||||||
|   // But even then, we don't know if it actually succeeded |   // But even then, we don't know if it actually succeeded | ||||||
|   Future<void> installApk(DownloadedApp file) async { |   Future<void> installApk(DownloadedApk file) async { | ||||||
|     var newInfo = await PackageArchiveInfo.fromPath(file.file.path); |     var newInfo = await PackageArchiveInfo.fromPath(file.file.path); | ||||||
|     AppInfo? appInfo; |     AppInfo? appInfo; | ||||||
|     try { |     try { | ||||||
| @@ -188,8 +209,9 @@ class AppsProvider with ChangeNotifier { | |||||||
|       // OK |       // OK | ||||||
|     } |     } | ||||||
|     if (appInfo != null && |     if (appInfo != null && | ||||||
|         int.parse(newInfo.buildNumber) < appInfo.versionCode!) { |         int.parse(newInfo.buildNumber) < appInfo.versionCode! && | ||||||
|       throw 'Can\'t install an older version'; |         !(await canDowngradeApps())) { | ||||||
|  |       throw DowngradeError(); | ||||||
|     } |     } | ||||||
|     if (appInfo == null || |     if (appInfo == null || | ||||||
|         int.parse(newInfo.buildNumber) > appInfo.versionCode!) { |         int.parse(newInfo.buildNumber) > appInfo.versionCode!) { | ||||||
| @@ -198,17 +220,25 @@ class AppsProvider with ChangeNotifier { | |||||||
|     apps[file.appId]!.app.installedVersion = |     apps[file.appId]!.app.installedVersion = | ||||||
|         apps[file.appId]!.app.latestVersion; |         apps[file.appId]!.app.latestVersion; | ||||||
|     // Don't correct install status as installation may not be done yet |     // Don't correct install status as installation may not be done yet | ||||||
|     await saveApps([apps[file.appId]!.app], shouldCorrectInstallStatus: false); |     await saveApps([apps[file.appId]!.app], | ||||||
|  |         attemptToCorrectInstallStatus: false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<String?> selectApkUrl(App app, BuildContext? context) async { |   Future<String?> confirmApkUrl(App app, BuildContext? context) async { | ||||||
|     // If the App has more than one APK, the user should pick one (if context provided) |     // If the App has more than one APK, the user should pick one (if context provided) | ||||||
|     String? apkUrl = app.apkUrls[app.preferredApkIndex]; |     String? apkUrl = app.apkUrls[app.preferredApkIndex]; | ||||||
|  |     // get device supported architecture | ||||||
|  |     List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis; | ||||||
|  |  | ||||||
|     if (app.apkUrls.length > 1 && context != null) { |     if (app.apkUrls.length > 1 && context != null) { | ||||||
|       apkUrl = await showDialog( |       apkUrl = await showDialog( | ||||||
|           context: context, |           context: context, | ||||||
|           builder: (BuildContext ctx) { |           builder: (BuildContext ctx) { | ||||||
|             return APKPicker(app: app, initVal: apkUrl); |             return APKPicker( | ||||||
|  |               app: app, | ||||||
|  |               initVal: apkUrl, | ||||||
|  |               archs: archs, | ||||||
|  |             ); | ||||||
|           }); |           }); | ||||||
|     } |     } | ||||||
|     // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) |     // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) | ||||||
| @@ -228,15 +258,6 @@ class AppsProvider with ChangeNotifier { | |||||||
|     return apkUrl; |     return apkUrl; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Map<String, List<String>> addToErrorMap( |  | ||||||
|       Map<String, List<String>> errors, String appId, String error) { |  | ||||||
|     var tempIds = errors.remove(error); |  | ||||||
|     tempIds ??= []; |  | ||||||
|     tempIds.add(appId); |  | ||||||
|     errors.putIfAbsent(error, () => tempIds!); |  | ||||||
|     return errors; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Given a list of AppIds, uses stored info about the apps to download APKs and install them |   // Given a list of AppIds, uses stored info about the apps to download APKs and install them | ||||||
|   // If the APKs can be installed silently, they are |   // If the APKs can be installed silently, they are | ||||||
|   // If no BuildContext is provided, apps that require user interaction are ignored |   // If no BuildContext is provided, apps that require user interaction are ignored | ||||||
| @@ -245,42 +266,41 @@ class AppsProvider with ChangeNotifier { | |||||||
|   Future<List<String>> downloadAndInstallLatestApps( |   Future<List<String>> downloadAndInstallLatestApps( | ||||||
|       List<String> appIds, BuildContext? context) async { |       List<String> appIds, BuildContext? context) async { | ||||||
|     List<String> appsToInstall = []; |     List<String> appsToInstall = []; | ||||||
|  |     // For all specified Apps, filter out those for which: | ||||||
|  |     // 1. A URL cannot be picked | ||||||
|  |     // 2. That cannot be installed silently (IF no buildContext was given for interactive install) | ||||||
|     for (var id in appIds) { |     for (var id in appIds) { | ||||||
|       if (apps[id] == null) { |       if (apps[id] == null) { | ||||||
|         throw 'App not found'; |         throw ObtainiumError('App not found'); | ||||||
|       } |       } | ||||||
|  |       String? apkUrl = await confirmApkUrl(apps[id]!.app, context); | ||||||
|       String? apkUrl = await selectApkUrl(apps[id]!.app, context); |  | ||||||
|  |  | ||||||
|       if (apkUrl != null) { |       if (apkUrl != null) { | ||||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); |         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { |         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||||
|           apps[id]!.app.preferredApkIndex = urlInd; |           apps[id]!.app.preferredApkIndex = urlInd; | ||||||
|           await saveApps([apps[id]!.app]); |           await saveApps([apps[id]!.app]); | ||||||
|         } |         } | ||||||
|         if (context != null || |         if (context != null || await canInstallSilently(apps[id]!.app)) { | ||||||
|             (await canInstallSilently(apps[id]!.app) && |  | ||||||
|                 apps[id]!.app.apkUrls.length == 1)) { |  | ||||||
|           appsToInstall.add(id); |           appsToInstall.add(id); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     Map<String, List<String>> errors = {}; |     // Download APKs for all Apps to be installed | ||||||
|  |     MultiAppMultiError errors = MultiAppMultiError(); | ||||||
|     List<DownloadedApp?> downloadedFiles = |     List<DownloadedApk?> downloadedFiles = | ||||||
|         await Future.wait(appsToInstall.map((id) async { |         await Future.wait(appsToInstall.map((id) async { | ||||||
|       try { |       try { | ||||||
|         return await downloadApp(apps[id]!.app); |         return await downloadApp(apps[id]!.app); | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         addToErrorMap(errors, id, e.toString()); |         errors.add(id, e.toString()); | ||||||
|       } |       } | ||||||
|       return null; |       return null; | ||||||
|     })); |     })); | ||||||
|     downloadedFiles = |     downloadedFiles = | ||||||
|         downloadedFiles.where((element) => element != null).toList(); |         downloadedFiles.where((element) => element != null).toList(); | ||||||
|  |     // Separate the Apps to install into silent and regular lists | ||||||
|     List<DownloadedApp> silentUpdates = []; |     List<DownloadedApk> silentUpdates = []; | ||||||
|     List<DownloadedApp> regularInstalls = []; |     List<DownloadedApk> regularInstalls = []; | ||||||
|     for (var f in downloadedFiles) { |     for (var f in downloadedFiles) { | ||||||
|       bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); |       bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); | ||||||
|       if (willBeSilent) { |       if (willBeSilent) { | ||||||
| @@ -290,56 +310,53 @@ class AppsProvider with ChangeNotifier { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Move everything to the regular install list (since silent updates don't currently work) - TODO | ||||||
|  |     regularInstalls.addAll(silentUpdates); | ||||||
|  |  | ||||||
|     // If Obtainium is being installed, it should be the last one |     // If Obtainium is being installed, it should be the last one | ||||||
|     List<DownloadedApp> moveObtainiumToEnd(List<DownloadedApp> items) { |     List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) { | ||||||
|       String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; |       DownloadedApk? temp; | ||||||
|       DownloadedApp? temp; |  | ||||||
|       items.removeWhere((element) { |       items.removeWhere((element) { | ||||||
|         bool res = element.appId == obtainiumId; |         bool res = | ||||||
|  |             element.appId == obtainiumId || element.appId == obtainiumTempId; | ||||||
|         if (res) { |         if (res) { | ||||||
|           temp = element; |           temp = element; | ||||||
|         } |         } | ||||||
|         return res; |         return res; | ||||||
|       }); |       }); | ||||||
|       if (temp != null) { |       if (temp != null) { | ||||||
|         items.add(temp!); |         items = [temp!, ...items]; | ||||||
|       } |       } | ||||||
|       return items; |       return items; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // TODO: Remove below line if silentupdates are ever figured out |     silentUpdates = moveObtainiumToStart(silentUpdates); | ||||||
|     regularInstalls.addAll(silentUpdates); |     regularInstalls = moveObtainiumToStart(regularInstalls); | ||||||
|  |  | ||||||
|     silentUpdates = moveObtainiumToEnd(silentUpdates); |     // // Install silent updates (uncomment when it works - TODO) | ||||||
|     regularInstalls = moveObtainiumToEnd(regularInstalls); |  | ||||||
|  |  | ||||||
|     // TODO: Uncomment below if silentupdates are ever figured out |  | ||||||
|     // for (var u in silentUpdates) { |     // for (var u in silentUpdates) { | ||||||
|     //   await installApk(u, silent: true); // Would need to add silent option |     //   await installApk(u, silent: true); // Would need to add silent option | ||||||
|     // } |     // } | ||||||
|  |  | ||||||
|     if (context != null) { |     // Do regular installs | ||||||
|       if (regularInstalls.isNotEmpty) { |     if (regularInstalls.isNotEmpty && context != null) { | ||||||
|         // ignore: use_build_context_synchronously |       // ignore: use_build_context_synchronously | ||||||
|         await askUserToReturnToForeground(context, waitForFG: true); |       await waitForUserToReturnToForeground(context); | ||||||
|       } |  | ||||||
|       for (var i in regularInstalls) { |       for (var i in regularInstalls) { | ||||||
|         try { |         try { | ||||||
|           await installApk(i); |           await installApk(i); | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|           addToErrorMap(errors, i.appId, e.toString()); |           errors.add(i.appId, e.toString()); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (errors.isNotEmpty) { |  | ||||||
|       String finalError = ''; |     if (errors.content.isNotEmpty) { | ||||||
|       for (var e in errors.keys) { |       throw errors; | ||||||
|         finalError += |  | ||||||
|             '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; |  | ||||||
|       } |  | ||||||
|       throw finalError; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     NotificationsProvider().cancel(UpdateNotification([]).id); | ||||||
|  |  | ||||||
|     return downloadedFiles.map((e) => e!.appId).toList(); |     return downloadedFiles.map((e) => e!.appId).toList(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -352,40 +369,6 @@ class AppsProvider with ChangeNotifier { | |||||||
|     return appsDir; |     return appsDir; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Delete all stored APKs except those likely to still be needed |  | ||||||
|   Future<void> deleteSavedAPKs() async { |  | ||||||
|     List<FileSystemEntity>? apks = (await getExternalStorageDirectory()) |  | ||||||
|         ?.listSync() |  | ||||||
|         .where((element) => element.path.endsWith('.apk')) |  | ||||||
|         .toList(); |  | ||||||
|     if (apks != null && apks.isNotEmpty) { |  | ||||||
|       for (var apk in apks) { |  | ||||||
|         var shouldDelete = true; |  | ||||||
|         var temp = apk.path.split('/').last; |  | ||||||
|         temp = temp.substring(0, temp.length - 4); |  | ||||||
|         var fn = temp.split('-'); |  | ||||||
|         if (fn.length == 3) { |  | ||||||
|           var possibleId = fn[0]; |  | ||||||
|           var possibleVersion = fn[1]; |  | ||||||
|           var possibleApkUrlIndex = fn[2]; |  | ||||||
|           if (apps[possibleId] != null) { |  | ||||||
|             if (apps[possibleId] != null && |  | ||||||
|                 apps[possibleId]?.app != null && |  | ||||||
|                 apps[possibleId]!.app.installedVersion != |  | ||||||
|                     apps[possibleId]!.app.latestVersion && |  | ||||||
|                 apps[possibleId]!.app.latestVersion == possibleVersion && |  | ||||||
|                 apps[possibleId]!.app.preferredApkIndex.toString() == |  | ||||||
|                     possibleApkUrlIndex) { |  | ||||||
|               shouldDelete = false; |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (shouldDelete) apk.delete(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<AppInfo?> getInstalledInfo(String? packageName) async { |   Future<AppInfo?> getInstalledInfo(String? packageName) async { | ||||||
|     if (packageName != null) { |     if (packageName != null) { | ||||||
|       try { |       try { | ||||||
| @@ -397,24 +380,37 @@ class AppsProvider with ChangeNotifier { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   String standardizeVersionString(String versionString) { |   // If the App says it is installed but installedInfo is null, set it to not installed | ||||||
|     return versionString.characters |  | ||||||
|         .where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'] |  | ||||||
|             .contains(p0)) |  | ||||||
|         .join(''); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // If the App says it is installed by installedInfo is null, set it to not installed |  | ||||||
|   // If the App says is is not installed but installedInfo exists, try to set it to installed as latest version... |   // If the App says is is not installed but installedInfo exists, try to set it to installed as latest version... | ||||||
|   // ...if the latestVersion seems to match the version in installedInfo (not guaranteed) |   // ...if the latestVersion seems to match the version in installedInfo (not guaranteed) | ||||||
|   App? correctInstallStatus(App app, AppInfo? installedInfo) { |   // If that fails, just set it to the actual version string (all we can do at that point) | ||||||
|  |   // Don't save changes, just return the object if changes were made (else null) | ||||||
|  |   // If in a background isolate, return null straight away as the required plugin won't work anyways | ||||||
|  |   App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { | ||||||
|  |     if (forBGTask) { | ||||||
|  |       return null; // Can't correct in the background isolate | ||||||
|  |     } | ||||||
|     var modded = false; |     var modded = false; | ||||||
|     if (installedInfo == null && app.installedVersion != null) { |     if (installedInfo == null && app.installedVersion != null) { | ||||||
|       app.installedVersion = null; |       app.installedVersion = null; | ||||||
|       modded = true; |       modded = true; | ||||||
|     } |     } | ||||||
|     if (installedInfo != null && app.installedVersion == null) { |     if (installedInfo != null && app.installedVersion == null) { | ||||||
|       if (standardizeVersionString(app.latestVersion) == |       if (app.latestVersion.characters | ||||||
|  |               .where((p0) => [ | ||||||
|  |                     '0', | ||||||
|  |                     '1', | ||||||
|  |                     '2', | ||||||
|  |                     '3', | ||||||
|  |                     '4', | ||||||
|  |                     '5', | ||||||
|  |                     '6', | ||||||
|  |                     '7', | ||||||
|  |                     '8', | ||||||
|  |                     '9', | ||||||
|  |                     '.' | ||||||
|  |                   ].contains(p0)) | ||||||
|  |               .join('') == | ||||||
|           installedInfo.versionName) { |           installedInfo.versionName) { | ||||||
|         app.installedVersion = app.latestVersion; |         app.installedVersion = app.latestVersion; | ||||||
|       } else { |       } else { | ||||||
| @@ -425,28 +421,34 @@ class AppsProvider with ChangeNotifier { | |||||||
|     return modded ? app : null; |     return modded ? app : null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> loadApps({shouldCorrectInstallStatus = true}) async { |   Future<void> loadApps() async { | ||||||
|     while (loadingApps) { |     while (loadingApps) { | ||||||
|       await Future.delayed(const Duration(microseconds: 1)); |       await Future.delayed(const Duration(microseconds: 1)); | ||||||
|     } |     } | ||||||
|     loadingApps = true; |     loadingApps = true; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|     List<FileSystemEntity> appFiles = (await getAppsDir()) |     List<App> newApps = (await getAppsDir()) | ||||||
|         .listSync() |         .listSync() | ||||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) |         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||||
|  |         .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) | ||||||
|         .toList(); |         .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(); |     var sp = SourceProvider(); | ||||||
|     List<List<String>> errors = []; |     List<List<String>> errors = []; | ||||||
|     for (int i = 0; i < appFiles.length; i++) { |     for (int i = 0; i < newApps.length; i++) { | ||||||
|       App app = |       var info = await getInstalledInfo(newApps[i].id); | ||||||
|           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); |  | ||||||
|       var info = await getInstalledInfo(app.id); |  | ||||||
|       try { |       try { | ||||||
|         sp.getSource(app.url); |         sp.getSource(newApps[i].url); | ||||||
|         apps.putIfAbsent(app.id, () => AppInMemory(app, null, info)); |         apps.putIfAbsent( | ||||||
|  |             newApps[i].id, () => AppInMemory(newApps[i], null, info)); | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         errors.add([app.id, app.name, e.toString()]); |         errors.add([newApps[i].id, newApps[i].name, e.toString()]); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (errors.isNotEmpty) { |     if (errors.isNotEmpty) { | ||||||
| @@ -456,28 +458,26 @@ class AppsProvider with ChangeNotifier { | |||||||
|     } |     } | ||||||
|     loadingApps = false; |     loadingApps = false; | ||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|     // For any that are not installed (by ID == package name), set to not installed if needed |     List<App> modifiedApps = []; | ||||||
|     if (shouldCorrectInstallStatus) { |     for (var app in apps.values) { | ||||||
|       List<App> modifiedApps = []; |       var moddedApp = | ||||||
|       for (var app in apps.values) { |           getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo); | ||||||
|         var moddedApp = correctInstallStatus(app.app, app.installedInfo); |       if (moddedApp != null) { | ||||||
|         if (moddedApp != null) { |         modifiedApps.add(moddedApp); | ||||||
|           modifiedApps.add(moddedApp); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       if (modifiedApps.isNotEmpty) { |  | ||||||
|         await saveApps(modifiedApps, shouldCorrectInstallStatus: false); |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |     if (modifiedApps.isNotEmpty) { | ||||||
|  |       await saveApps(modifiedApps); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> saveApps(List<App> apps, |   Future<void> saveApps(List<App> apps, | ||||||
|       {bool shouldCorrectInstallStatus = true}) async { |       {bool attemptToCorrectInstallStatus = true}) async { | ||||||
|     for (var app in apps) { |     for (var app in apps) { | ||||||
|       AppInfo? info = await getInstalledInfo(app.id); |       AppInfo? info = await getInstalledInfo(app.id); | ||||||
|       app.name = info?.name ?? app.name; |       app.name = info?.name ?? app.name; | ||||||
|       if (shouldCorrectInstallStatus) { |       if (attemptToCorrectInstallStatus) { | ||||||
|         app = correctInstallStatus(app, info) ?? app; |         app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app; | ||||||
|       } |       } | ||||||
|       File('${(await getAppsDir()).path}/${app.id}.json') |       File('${(await getAppsDir()).path}/${app.id}.json') | ||||||
|           .writeAsStringSync(jsonEncode(app.toJson())); |           .writeAsStringSync(jsonEncode(app.toJson())); | ||||||
| @@ -503,15 +503,7 @@ class AppsProvider with ChangeNotifier { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool checkAppObjectForUpdate(App app) { |   Future<App?> checkUpdate(String appId) async { | ||||||
|     if (!apps.containsKey(app.id)) { |  | ||||||
|       throw 'App not found'; |  | ||||||
|     } |  | ||||||
|     return app.latestVersion != apps[app.id]?.app.installedVersion; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<App?> getUpdate(String appId, |  | ||||||
|       {bool shouldCorrectInstallStatus = true}) async { |  | ||||||
|     App? currentApp = apps[appId]!.app; |     App? currentApp = apps[appId]!.app; | ||||||
|     SourceProvider sourceProvider = SourceProvider(); |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|     App newApp = await sourceProvider.getApp( |     App newApp = await sourceProvider.getApp( | ||||||
| @@ -519,56 +511,45 @@ class AppsProvider with ChangeNotifier { | |||||||
|         currentApp.url, |         currentApp.url, | ||||||
|         currentApp.additionalData, |         currentApp.additionalData, | ||||||
|         name: currentApp.name, |         name: currentApp.name, | ||||||
|         id: currentApp.id); |         id: currentApp.id, | ||||||
|  |         pinned: currentApp.pinned); | ||||||
|     newApp.installedVersion = currentApp.installedVersion; |     newApp.installedVersion = currentApp.installedVersion; | ||||||
|     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { |     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||||
|       newApp.preferredApkIndex = currentApp.preferredApkIndex; |       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||||
|     } |     } | ||||||
|     await saveApps([newApp], |     await saveApps([newApp]); | ||||||
|         shouldCorrectInstallStatus: shouldCorrectInstallStatus); |  | ||||||
|     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; |     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<App>> checkUpdates( |   Future<List<App>> checkUpdates( | ||||||
|       {DateTime? ignoreAfter, |       {DateTime? ignoreAppsCheckedAfter, | ||||||
|       bool immediatelyThrowRateLimitError = false, |       bool throwErrorsForRetry = false}) async { | ||||||
|       bool shouldCorrectInstallStatus = true, |  | ||||||
|       bool immediatelyThrowSocketError = false}) async { |  | ||||||
|     List<App> updates = []; |     List<App> updates = []; | ||||||
|     Map<String, List<String>> errors = {}; |     MultiAppMultiError errors = MultiAppMultiError(); | ||||||
|     if (!gettingUpdates) { |     if (!gettingUpdates) { | ||||||
|       gettingUpdates = true; |       gettingUpdates = true; | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         List<String> appIds = apps.keys.toList(); |         List<String> appIds = apps.values | ||||||
|         if (ignoreAfter != null) { |             .where((app) => | ||||||
|           appIds = appIds |                 app.app.lastUpdateCheck == null || | ||||||
|               .where((id) => |                 ignoreAppsCheckedAfter == null || | ||||||
|                   apps[id]!.app.lastUpdateCheck == null || |                 app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter)) | ||||||
|                   apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) |             .map((e) => e.app.id) | ||||||
|               .toList(); |             .toList(); | ||||||
|         } |  | ||||||
|         appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? |         appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? | ||||||
|                 DateTime.fromMicrosecondsSinceEpoch(0)) |                 DateTime.fromMicrosecondsSinceEpoch(0)) | ||||||
|             .compareTo(apps[b]!.app.lastUpdateCheck ?? |             .compareTo(apps[b]!.app.lastUpdateCheck ?? | ||||||
|                 DateTime.fromMicrosecondsSinceEpoch(0))); |                 DateTime.fromMicrosecondsSinceEpoch(0))); | ||||||
|  |  | ||||||
|         for (int i = 0; i < appIds.length; i++) { |         for (int i = 0; i < appIds.length; i++) { | ||||||
|           App? newApp; |           App? newApp; | ||||||
|           try { |           try { | ||||||
|             newApp = await getUpdate(appIds[i], |             newApp = await checkUpdate(appIds[i]); | ||||||
|                 shouldCorrectInstallStatus: shouldCorrectInstallStatus); |  | ||||||
|           } catch (e) { |           } catch (e) { | ||||||
|             if (e is RateLimitError && immediatelyThrowRateLimitError) { |             if ((e is RateLimitError || e is SocketException) && | ||||||
|  |                 throwErrorsForRetry) { | ||||||
|               rethrow; |               rethrow; | ||||||
|             } |             } | ||||||
|             if (e is SocketException && immediatelyThrowSocketError) { |             errors.add(appIds[i], e.toString()); | ||||||
|               rethrow; |  | ||||||
|             } |  | ||||||
|             var tempIds = errors.remove(e.toString()); |  | ||||||
|             tempIds ??= []; |  | ||||||
|             tempIds.add(appIds[i]); |  | ||||||
|             errors.putIfAbsent(e.toString(), () => tempIds!); |  | ||||||
|           } |           } | ||||||
|           if (newApp != null) { |           if (newApp != null) { | ||||||
|             updates.add(newApp); |             updates.add(newApp); | ||||||
| @@ -578,18 +559,13 @@ class AppsProvider with ChangeNotifier { | |||||||
|         gettingUpdates = false; |         gettingUpdates = false; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (errors.isNotEmpty) { |     if (errors.content.isNotEmpty) { | ||||||
|       String finalError = ''; |       throw errors; | ||||||
|       for (var e in errors.keys) { |  | ||||||
|         finalError += |  | ||||||
|             '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; |  | ||||||
|       } |  | ||||||
|       throw finalError; |  | ||||||
|     } |     } | ||||||
|     return updates; |     return updates; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   List<String> getExistingUpdates( |   List<String> findExistingUpdates( | ||||||
|       {bool installedOnly = false, bool nonInstalledOnly = false}) { |       {bool installedOnly = false, bool nonInstalledOnly = false}) { | ||||||
|     List<String> updateAppIds = []; |     List<String> updateAppIds = []; | ||||||
|     List<String> appIds = apps.keys.toList(); |     List<String> appIds = apps.keys.toList(); | ||||||
| @@ -623,7 +599,6 @@ class AppsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<int> importApps(String appsJSON) async { |   Future<int> importApps(String appsJSON) async { | ||||||
|     // File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps |  | ||||||
|     List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) |     List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) | ||||||
|         .map((e) => App.fromJson(e)) |         .map((e) => App.fromJson(e)) | ||||||
|         .toList(); |         .toList(); | ||||||
| @@ -648,10 +623,11 @@ class AppsProvider with ChangeNotifier { | |||||||
| } | } | ||||||
|  |  | ||||||
| class APKPicker extends StatefulWidget { | class APKPicker extends StatefulWidget { | ||||||
|   const APKPicker({super.key, required this.app, this.initVal}); |   const APKPicker({super.key, required this.app, this.initVal, this.archs}); | ||||||
|  |  | ||||||
|   final App app; |   final App app; | ||||||
|   final String? initVal; |   final String? initVal; | ||||||
|  |   final List<String>? archs; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   State<APKPicker> createState() => _APKPickerState(); |   State<APKPicker> createState() => _APKPickerState(); | ||||||
| @@ -669,18 +645,29 @@ class _APKPickerState extends State<APKPicker> { | |||||||
|       content: Column(children: [ |       content: Column(children: [ | ||||||
|         Text('${widget.app.name} has more than one package:'), |         Text('${widget.app.name} has more than one package:'), | ||||||
|         const SizedBox(height: 16), |         const SizedBox(height: 16), | ||||||
|         ...widget.app.apkUrls.map((u) => RadioListTile<String>( |         ...widget.app.apkUrls.map( | ||||||
|             title: Text(Uri.parse(u) |           (u) => RadioListTile<String>( | ||||||
|                 .pathSegments |               title: Text(Uri.parse(u) | ||||||
|                 .where((element) => element.isNotEmpty) |                   .pathSegments | ||||||
|                 .last), |                   .where((element) => element.isNotEmpty) | ||||||
|             value: u, |                   .last), | ||||||
|             groupValue: apkUrl, |               value: u, | ||||||
|             onChanged: (String? val) { |               groupValue: apkUrl, | ||||||
|               setState(() { |               onChanged: (String? val) { | ||||||
|                 apkUrl = val; |                 setState(() { | ||||||
|               }); |                   apkUrl = val; | ||||||
|             })) |                 }); | ||||||
|  |               }), | ||||||
|  |         ), | ||||||
|  |         if (widget.archs != null) | ||||||
|  |           const SizedBox( | ||||||
|  |             height: 16, | ||||||
|  |           ), | ||||||
|  |         if (widget.archs != null) | ||||||
|  |           Text( | ||||||
|  |             'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}', | ||||||
|  |             style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), | ||||||
|  |           ), | ||||||
|       ]), |       ]), | ||||||
|       actions: [ |       actions: [ | ||||||
|         TextButton( |         TextButton( | ||||||
|   | |||||||
							
								
								
									
										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); | ||||||
|  | } | ||||||
| @@ -27,9 +27,11 @@ class UpdateNotification extends ObtainiumNotification { | |||||||
|             'Updates Available', |             'Updates Available', | ||||||
|             'Notifies the user that updates are available for one or more Apps tracked by Obtainium', |             'Notifies the user that updates are available for one or more Apps tracked by Obtainium', | ||||||
|             Importance.max) { |             Importance.max) { | ||||||
|     message = updates.length == 1 |     message = updates.isEmpty | ||||||
|         ? '${updates[0].name} has an update.' |         ? "No new updates." | ||||||
|         : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; |         : updates.length == 1 | ||||||
|  |             ? '${updates[0].name} has an update.' | ||||||
|  |             : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,9 +2,13 @@ | |||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:fluttertoast/fluttertoast.dart'; | import 'package:fluttertoast/fluttertoast.dart'; | ||||||
|  | import 'package:obtainium/app_sources/github.dart'; | ||||||
| import 'package:permission_handler/permission_handler.dart'; | import 'package:permission_handler/permission_handler.dart'; | ||||||
| import 'package:shared_preferences/shared_preferences.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 ThemeSettings { system, light, dark } | ||||||
|  |  | ||||||
| enum ColourSettings { basic, materialYou } | enum ColourSettings { basic, materialYou } | ||||||
| @@ -55,7 +59,7 @@ class SettingsProvider with ChangeNotifier { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   int get updateInterval { |   int get updateInterval { | ||||||
|     var min = prefs?.getInt('updateInterval') ?? 180; |     var min = prefs?.getInt('updateInterval') ?? 360; | ||||||
|     if (!updateIntervals.contains(min)) { |     if (!updateIntervals.contains(min)) { | ||||||
|       var temp = updateIntervals[0]; |       var temp = updateIntervals[0]; | ||||||
|       for (var i in updateIntervals) { |       for (var i in updateIntervals) { | ||||||
| @@ -123,6 +127,15 @@ class SettingsProvider with ChangeNotifier { | |||||||
|     notifyListeners(); |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool get pinUpdates { | ||||||
|  |     return prefs?.getBool('pinUpdates') ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set pinUpdates(bool show) { | ||||||
|  |     prefs?.setBool('pinUpdates', show); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   String? getSettingString(String settingId) { |   String? getSettingString(String settingId) { | ||||||
|     return prefs?.getString(settingId); |     return prefs?.getString(settingId); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ | |||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:html/dom.dart'; | import 'package:html/dom.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
| import 'package:obtainium/app_sources/fdroid.dart'; | import 'package:obtainium/app_sources/fdroid.dart'; | ||||||
| import 'package:obtainium/app_sources/github.dart'; | import 'package:obtainium/app_sources/github.dart'; | ||||||
| import 'package:obtainium/app_sources/gitlab.dart'; | import 'package:obtainium/app_sources/gitlab.dart'; | ||||||
| @@ -12,6 +13,7 @@ import 'package:obtainium/app_sources/mullvad.dart'; | |||||||
| import 'package:obtainium/app_sources/signal.dart'; | import 'package:obtainium/app_sources/signal.dart'; | ||||||
| import 'package:obtainium/app_sources/sourceforge.dart'; | import 'package:obtainium/app_sources/sourceforge.dart'; | ||||||
| import 'package:obtainium/components/generated_form.dart'; | import 'package:obtainium/components/generated_form.dart'; | ||||||
|  | import 'package:obtainium/custom_errors.dart'; | ||||||
| import 'package:obtainium/mass_app_sources/githubstars.dart'; | import 'package:obtainium/mass_app_sources/githubstars.dart'; | ||||||
|  |  | ||||||
| class AppNames { | class AppNames { | ||||||
| @@ -39,6 +41,7 @@ class App { | |||||||
|   late int preferredApkIndex; |   late int preferredApkIndex; | ||||||
|   late List<String> additionalData; |   late List<String> additionalData; | ||||||
|   late DateTime? lastUpdateCheck; |   late DateTime? lastUpdateCheck; | ||||||
|  |   bool pinned = false; | ||||||
|   App( |   App( | ||||||
|       this.id, |       this.id, | ||||||
|       this.url, |       this.url, | ||||||
| @@ -49,11 +52,12 @@ class App { | |||||||
|       this.apkUrls, |       this.apkUrls, | ||||||
|       this.preferredApkIndex, |       this.preferredApkIndex, | ||||||
|       this.additionalData, |       this.additionalData, | ||||||
|       this.lastUpdateCheck); |       this.lastUpdateCheck, | ||||||
|  |       this.pinned); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   String toString() { |   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( |   factory App.fromJson(Map<String, dynamic> json) => App( | ||||||
| @@ -74,7 +78,8 @@ class App { | |||||||
|           : List<String>.from(jsonDecode(json['additionalData'])), |           : List<String>.from(jsonDecode(json['additionalData'])), | ||||||
|       json['lastUpdateCheck'] == null |       json['lastUpdateCheck'] == null | ||||||
|           ? null |           ? null | ||||||
|           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); |           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), | ||||||
|  |       json['pinned'] ?? false); | ||||||
|  |  | ||||||
|   Map<String, dynamic> toJson() => { |   Map<String, dynamic> toJson() => { | ||||||
|         'id': id, |         'id': id, | ||||||
| @@ -86,16 +91,12 @@ class App { | |||||||
|         'apkUrls': jsonEncode(apkUrls), |         'apkUrls': jsonEncode(apkUrls), | ||||||
|         'preferredApkIndex': preferredApkIndex, |         'preferredApkIndex': preferredApkIndex, | ||||||
|         'additionalData': jsonEncode(additionalData), |         'additionalData': jsonEncode(additionalData), | ||||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch |         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||||
|  |         'pinned': pinned | ||||||
|       }; |       }; | ||||||
| } | } | ||||||
|  |  | ||||||
| escapeRegEx(String s) { | // Ensure the input is starts with HTTPS and has no WWW | ||||||
|   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { |  | ||||||
|     return '\\${x[0]}'; |  | ||||||
|   }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| preStandardizeUrl(String url) { | preStandardizeUrl(String url) { | ||||||
|   if (url.toLowerCase().indexOf('http://') != 0 && |   if (url.toLowerCase().indexOf('http://') != 0 && | ||||||
|       url.toLowerCase().indexOf('https://') != 0) { |       url.toLowerCase().indexOf('https://') != 0) { | ||||||
| @@ -104,6 +105,11 @@ preStandardizeUrl(String url) { | |||||||
|   if (url.toLowerCase().indexOf('https://www.') == 0) { |   if (url.toLowerCase().indexOf('https://www.') == 0) { | ||||||
|     url = 'https://${url.substring(12)}'; |     url = 'https://${url.substring(12)}'; | ||||||
|   } |   } | ||||||
|  |   url = url | ||||||
|  |       .split('/') | ||||||
|  |       .where((e) => e.isNotEmpty) | ||||||
|  |       .join('/') | ||||||
|  |       .replaceFirst(':/', '://'); | ||||||
|   return url; |   return url; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -127,23 +133,51 @@ List<String> getLinksFromParsedHTML( | |||||||
|         .map((e) => '$prependToLinks${e.attributes['href']!}') |         .map((e) => '$prependToLinks${e.attributes['href']!}') | ||||||
|         .toList(); |         .toList(); | ||||||
|  |  | ||||||
| abstract class AppSource { | class AppSource { | ||||||
|   late String host; |   late String host; | ||||||
|   String standardizeURL(String url); |   String standardizeURL(String url) { | ||||||
|  |     throw NotImplementedError(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<APKDetails> getLatestAPKDetails( |   Future<APKDetails> getLatestAPKDetails( | ||||||
|       String standardUrl, List<String> additionalData); |       String standardUrl, List<String> additionalData) { | ||||||
|   AppNames getAppNames(String standardUrl); |     throw NotImplementedError(); | ||||||
|   late List<List<GeneratedFormItem>> additionalDataFormItems; |   } | ||||||
|   late List<String> additionalDataDefaults; |  | ||||||
|   late List<GeneratedFormItem> moreSourceSettingsFormItems; |   AppNames getAppNames(String standardUrl) { | ||||||
|   String? changeLogPageFromStandardUrl(String standardUrl); |     throw NotImplementedError(); | ||||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl); |   } | ||||||
|  |  | ||||||
|  |   List<List<GeneratedFormItem>> additionalDataFormItems = []; | ||||||
|  |   List<String> additionalDataDefaults = []; | ||||||
|  |   List<GeneratedFormItem> moreSourceSettingsFormItems = []; | ||||||
|  |   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; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| abstract class MassAppSource { | ObtainiumError getObtainiumHttpError(Response res) { | ||||||
|  |   return ObtainiumError( | ||||||
|  |       res.reasonPhrase ?? 'Error ${res.statusCode.toString()}'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class MassAppUrlSource { | ||||||
|   late String name; |   late String name; | ||||||
|   late List<String> requiredArgs; |   late List<String> requiredArgs; | ||||||
|   Future<List<String>> getUrls(List<String> args); |   Future<Map<String, String>> getUrlsWithDescriptions(List<String> args); | ||||||
| } | } | ||||||
|  |  | ||||||
| class SourceProvider { | class SourceProvider { | ||||||
| @@ -155,12 +189,11 @@ class SourceProvider { | |||||||
|     IzzyOnDroid(), |     IzzyOnDroid(), | ||||||
|     Mullvad(), |     Mullvad(), | ||||||
|     Signal(), |     Signal(), | ||||||
|     SourceForge(), |     SourceForge() | ||||||
|     // APKMirror() |  | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   // Add more mass source classes here so they are available via the service |   // Add more mass url source classes here so they are available via the service | ||||||
|   List<MassAppSource> massSources = [GitHubStars()]; |   List<MassAppUrlSource> massUrlSources = [GitHubStars()]; | ||||||
|  |  | ||||||
|   AppSource getSource(String url) { |   AppSource getSource(String url) { | ||||||
|     url = preStandardizeUrl(url); |     url = preStandardizeUrl(url); | ||||||
| @@ -172,12 +205,12 @@ class SourceProvider { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (source == null) { |     if (source == null) { | ||||||
|       throw 'URL does not match a known source'; |       throw UnsupportedURLError(); | ||||||
|     } |     } | ||||||
|     return source; |     return source; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool doesSourceHaveRequiredAdditionalData(AppSource source) { |   bool ifSourceAppsRequireAdditionalData(AppSource source) { | ||||||
|     for (var row in source.additionalDataFormItems) { |     for (var row in source.additionalDataFormItems) { | ||||||
|       for (var element in row) { |       for (var element in row) { | ||||||
|         if (element.required) { |         if (element.required) { | ||||||
| @@ -191,29 +224,44 @@ class SourceProvider { | |||||||
|   String generateTempID(AppNames names, AppSource source) => |   String generateTempID(AppNames names, AppSource source) => | ||||||
|       '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; |       '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; | ||||||
|  |  | ||||||
|  |   bool isTempId(String id) { | ||||||
|  |     List<String> parts = id.split('_'); | ||||||
|  |     if (parts.length < 3) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |     for (int i = 0; i < parts.length - 1; i++) { | ||||||
|  |       if (RegExp('.*[A-Z].*').hasMatch(parts[i])) { | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return getSourceHosts().contains(parts.last); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   Future<App> getApp(AppSource source, String url, List<String> additionalData, |   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||||
|       {String name = '', String? id}) async { |       {String name = '', String? id, bool pinned = false}) async { | ||||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); |     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||||
|     AppNames names = source.getAppNames(standardUrl); |     AppNames names = source.getAppNames(standardUrl); | ||||||
|     APKDetails apk = |     APKDetails apk = | ||||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); |         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||||
|     return App( |     return App( | ||||||
|         id ?? generateTempID(names, source), |         id ?? | ||||||
|  |             source.tryInferringAppId(standardUrl) ?? | ||||||
|  |             generateTempID(names, source), | ||||||
|         standardUrl, |         standardUrl, | ||||||
|         names.author[0].toUpperCase() + names.author.substring(1), |         names.author[0].toUpperCase() + names.author.substring(1), | ||||||
|         name.trim().isNotEmpty |         name.trim().isNotEmpty | ||||||
|             ? name |             ? name | ||||||
|             : names.name[0].toUpperCase() + names.name.substring(1), |             : names.name[0].toUpperCase() + names.name.substring(1), | ||||||
|         null, |         null, | ||||||
|         apk.version, |         apk.version.replaceAll('/', '-'), | ||||||
|         apk.apkUrls, |         apk.apkUrls, | ||||||
|         apk.apkUrls.length - 1, |         apk.apkUrls.length - 1, | ||||||
|         additionalData, |         additionalData, | ||||||
|         DateTime.now()); |         DateTime.now(), | ||||||
|  |         pinned); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /// Returns a length 2 list, where the first element is a list of Apps and |   // Returns errors in [results, errors] instead of throwing them | ||||||
|   /// the second is a Map<String, dynamic> of URLs and errors |  | ||||||
|   Future<List<dynamic>> getApps(List<String> urls, |   Future<List<dynamic>> getApps(List<String> urls, | ||||||
|       {List<String> ignoreUrls = const []}) async { |       {List<String> ignoreUrls = const []}) async { | ||||||
|     List<App> apps = []; |     List<App> apps = []; | ||||||
|   | |||||||
							
								
								
									
										59
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,13 @@ | |||||||
| # Generated by pub | # Generated by pub | ||||||
| # See https://dart.dev/tools/pub/glossary#lockfile | # See https://dart.dev/tools/pub/glossary#lockfile | ||||||
| packages: | packages: | ||||||
|  |   android_alarm_manager_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: android_alarm_manager_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.1.0" | ||||||
|   animations: |   animations: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -14,7 +21,7 @@ packages: | |||||||
|       name: archive |       name: archive | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.3.2" |     version: "3.3.4" | ||||||
|   args: |   args: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -71,6 +78,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.16.0" |     version: "1.16.0" | ||||||
|  |   convert: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: convert | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.1.1" | ||||||
|   cross_file: |   cross_file: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -166,7 +180,7 @@ packages: | |||||||
|       name: flutter_fgbg |       name: flutter_fgbg | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.2.0" |     version: "0.2.1" | ||||||
|   flutter_launcher_icons: |   flutter_launcher_icons: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: |     description: | ||||||
| @@ -358,7 +372,7 @@ packages: | |||||||
|       name: path_provider_android |       name: path_provider_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.20" |     version: "2.0.21" | ||||||
|   path_provider_ios: |   path_provider_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -450,6 +464,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.3" |     version: "2.1.3" | ||||||
|  |   pointycastle: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: pointycastle | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.6.2" | ||||||
|   process: |   process: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -470,7 +491,7 @@ packages: | |||||||
|       name: share_plus |       name: share_plus | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.0" |     version: "6.2.0" | ||||||
|   share_plus_platform_interface: |   share_plus_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -546,6 +567,20 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.0" |     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: |   stack_trace: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -567,6 +602,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.1" |     version: "1.1.1" | ||||||
|  |   synchronized: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: synchronized | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0+3" | ||||||
|   term_glyph: |   term_glyph: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -699,14 +741,7 @@ packages: | |||||||
|       name: win32 |       name: win32 | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.0.1" |     version: "3.1.1" | ||||||
|   workmanager: |  | ||||||
|     dependency: "direct main" |  | ||||||
|     description: |  | ||||||
|       name: workmanager |  | ||||||
|       url: "https://pub.dartlang.org" |  | ||||||
|     source: hosted |  | ||||||
|     version: "0.5.1" |  | ||||||
|   xdg_directories: |   xdg_directories: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | |||||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||||
| # In Windows, build-name is used as the major, minor, and patch parts | # In Windows, build-name is used as the major, minor, and patch parts | ||||||
| # of the product and file versions while build-number is used as the build suffix. | # of the product and file versions while build-number is used as the build suffix. | ||||||
| version: 0.6.0+44 # When changing this, update the tag in main() accordingly | version: 0.7.5+61 # When changing this, update the tag in main() accordingly | ||||||
|  |  | ||||||
| environment: | environment: | ||||||
|   sdk: '>=2.18.2 <3.0.0' |   sdk: '>=2.18.2 <3.0.0' | ||||||
| @@ -42,7 +42,6 @@ dependencies: | |||||||
|   provider: ^6.0.3 |   provider: ^6.0.3 | ||||||
|   http: ^0.13.5 |   http: ^0.13.5 | ||||||
|   webview_flutter: ^3.0.4 |   webview_flutter: ^3.0.4 | ||||||
|   workmanager: ^0.5.0 |  | ||||||
|   dynamic_color: ^1.5.4 |   dynamic_color: ^1.5.4 | ||||||
|   html: ^0.15.0 |   html: ^0.15.0 | ||||||
|   shared_preferences: ^2.0.15 |   shared_preferences: ^2.0.15 | ||||||
| @@ -56,6 +55,8 @@ dependencies: | |||||||
|   share_plus: ^6.0.1 |   share_plus: ^6.0.1 | ||||||
|   installed_apps: ^1.3.1 |   installed_apps: ^1.3.1 | ||||||
|   package_archive_info: ^0.1.0 |   package_archive_info: ^0.1.0 | ||||||
|  |   android_alarm_manager_plus: ^2.1.0 | ||||||
|  |   sqflite: ^2.2.0+3 | ||||||
|  |  | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   | |||||||