Compare commits
	
		
			17 Commits
		
	
	
		
			v0.5.6-bet
			...
			v0.6.5-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 76e98feeb7 | ||
|  | 03da23f77a | ||
|  | 9b99e2b302 | ||
|  | e746ca890a | ||
|  | 9c00a7da14 | ||
|  | 4df0dd64ad | ||
|  | 7cf7ffe0de | ||
|  | b1953435af | ||
|  | fc7d7d11d6 | ||
|  | 9ef26b3a4a | ||
|  | 27ee6b9e88 | ||
|  | d1a3529036 | ||
|  | a954a627fd | ||
|  | 52ce5b19c4 | ||
|  | 03f0b6cf05 | ||
|  | 5d8d0de8de | ||
|  | 07f6d4ad2c | 
| @@ -13,7 +13,6 @@ Currently supported App sources: | ||||
| - [IzzyOnDroid](https://android.izzysoft.de/) | ||||
| - [Mullvad](https://mullvad.net/en/) | ||||
| - [Signal](https://signal.org/) | ||||
| - [APKMirror](https://apkmirror.com/) | ||||
|  | ||||
| ## Limitations | ||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||
|   | ||||
| Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 228 KiB | 
| Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 162 KiB | 
| Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 170 KiB | 
| Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 146 KiB | 
| Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 188 KiB | 
| Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB | 
| @@ -34,23 +34,39 @@ class FDroid implements AppSource { | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     Response res = await get(Uri.parse(standardUrl)); | ||||
|     if (res.statusCode == 200) { | ||||
|       var latestReleaseDiv = | ||||
|           parse(res.body).querySelector('#latest.package-version'); | ||||
|       var apkUrl = latestReleaseDiv | ||||
|           ?.querySelector('.package-version-download a') | ||||
|           ?.attributes['href']; | ||||
|       if (apkUrl == null) { | ||||
|         throw noAPKFound; | ||||
|       var releases = parse(res.body).querySelectorAll('.package-version'); | ||||
|       if (releases.isEmpty) { | ||||
|         throw couldNotFindReleases; | ||||
|       } | ||||
|       var version = latestReleaseDiv | ||||
|           ?.querySelector('.package-version-header b') | ||||
|       String? latestVersion = releases[0] | ||||
|           .querySelector('.package-version-header b') | ||||
|           ?.innerHtml | ||||
|           .split(' ') | ||||
|           .last; | ||||
|       if (version == null) { | ||||
|           .sublist(1) | ||||
|           .join(' '); | ||||
|       if (latestVersion == null) { | ||||
|         throw couldNotFindLatestVersion; | ||||
|       } | ||||
|       return APKDetails(version, [apkUrl]); | ||||
|       List<String> apkUrls = releases | ||||
|           .where((element) => | ||||
|               element | ||||
|                   .querySelector('.package-version-header b') | ||||
|                   ?.innerHtml | ||||
|                   .split(' ') | ||||
|                   .sublist(1) | ||||
|                   .join(' ') == | ||||
|               latestVersion) | ||||
|           .map((e) => | ||||
|               e | ||||
|                   .querySelector('.package-version-download a') | ||||
|                   ?.attributes['href'] ?? | ||||
|               '') | ||||
|           .where((element) => element.isNotEmpty) | ||||
|           .toList(); | ||||
|       if (apkUrls.isEmpty) { | ||||
|         throw noAPKFound; | ||||
|       } | ||||
|       return APKDetails(latestVersion, apkUrls); | ||||
|     } else { | ||||
|       throw couldNotFindReleases; | ||||
|     } | ||||
|   | ||||
| @@ -69,9 +69,10 @@ class GitHub implements AppSource { | ||||
|         if (!includePrereleases && releases[i]['prerelease'] == true) { | ||||
|           continue; | ||||
|         } | ||||
|  | ||||
|         if (regexFilter != null && | ||||
|             !RegExp(regexFilter) | ||||
|                 .hasMatch((releases[i]['name'] as String).trim())) { | ||||
|                 .hasMatch((releases[i]['tag_name'] as String).trim())) { | ||||
|           continue; | ||||
|         } | ||||
|         var apkUrls = getReleaseAPKUrls(releases[i]); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/pages/home.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| @@ -13,12 +14,14 @@ import 'package:workmanager/workmanager.dart'; | ||||
| import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
|  | ||||
| const String currentVersion = '0.6.5'; | ||||
| const String currentReleaseTag = | ||||
|     'v0.5.6-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| const String bgUpdateCheckTaskName = 'bg-update-check'; | ||||
|  | ||||
| bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   DateTime? ignoreAfter = ignoreAfterMicroseconds != null | ||||
|       ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) | ||||
|       : null; | ||||
| @@ -27,22 +30,28 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
|   try { | ||||
|     var appsProvider = AppsProvider(); | ||||
|     await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); | ||||
|     await appsProvider.loadApps(); | ||||
|     await appsProvider.loadApps(shouldCorrectInstallStatus: false); | ||||
|     List<String> existingUpdateIds = | ||||
|         appsProvider.getExistingUpdates(installedOnly: true); | ||||
|     DateTime nextIgnoreAfter = DateTime.now(); | ||||
|     String? err; | ||||
|     try { | ||||
|       await appsProvider.checkUpdates(ignoreAfter: ignoreAfter); | ||||
|       await appsProvider.checkUpdates( | ||||
|           ignoreAfter: ignoreAfter, | ||||
|           immediatelyThrowRateLimitError: true, | ||||
|           immediatelyThrowSocketError: true, | ||||
|           shouldCorrectInstallStatus: false); | ||||
|     } catch (e) { | ||||
|       if (e is RateLimitError) { | ||||
|       if (e is RateLimitError || e is SocketException) { | ||||
|         String nextTaskName = | ||||
|             '$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}'; | ||||
|         Workmanager().registerOneOffTask(nextTaskName, nextTaskName, | ||||
|             constraints: Constraints(networkType: NetworkType.connected), | ||||
|             initialDelay: Duration(minutes: e.remainingMinutes), | ||||
|             initialDelay: Duration( | ||||
|                 minutes: e is RateLimitError ? e.remainingMinutes : 15), | ||||
|             inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); | ||||
|       } else { | ||||
|         rethrow; | ||||
|         err = e.toString(); | ||||
|       } | ||||
|     } | ||||
|     List<App> newUpdates = appsProvider | ||||
| @@ -66,13 +75,15 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async { | ||||
|     // } | ||||
|  | ||||
|     if (newUpdates.isNotEmpty) { | ||||
|       notificationsProvider.notify(UpdateNotification(newUpdates), | ||||
|           cancelExisting: true); | ||||
|       notificationsProvider.notify(UpdateNotification(newUpdates)); | ||||
|     } | ||||
|     if (err != null) { | ||||
|       throw err; | ||||
|     } | ||||
|     return Future.value(true); | ||||
|   } catch (e) { | ||||
|     notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()), | ||||
|         cancelExisting: true); | ||||
|     notificationsProvider | ||||
|         .notify(ErrorCheckingUpdatesNotification(e.toString())); | ||||
|     return Future.error(false); | ||||
|   } finally { | ||||
|     await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
| @@ -89,7 +100,7 @@ void bgTaskCallback() { | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||
|     SystemChrome.setSystemUIOverlayStyle( | ||||
|       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||
|     ); | ||||
| @@ -138,7 +149,7 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|         Permission.notification.request(); | ||||
|         appsProvider.saveApps([ | ||||
|           App( | ||||
|               'imranr98_obtainium_${GitHub().host}', | ||||
|               'dev.imranr.obtainium', | ||||
|               'https://github.com/ImranR98/Obtainium', | ||||
|               'ImranR98', | ||||
|               'Obtainium', | ||||
|   | ||||
| @@ -22,7 +22,6 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|   String userInput = ''; | ||||
|   AppSource? pickedSource; | ||||
|   List<String> additionalData = []; | ||||
|   String customName = ''; | ||||
|   bool validAdditionalData = true; | ||||
|  | ||||
|   @override | ||||
| @@ -80,9 +79,6 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                                 .doesSourceHaveRequiredAdditionalData( | ||||
|                                                     source) | ||||
|                                             : true; | ||||
|                                         if (source == null) { | ||||
|                                           customName = ''; | ||||
|                                         } | ||||
|                                       } | ||||
|                                     }); | ||||
|                                   }, | ||||
| @@ -90,59 +86,78 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                           const SizedBox( | ||||
|                             width: 16, | ||||
|                           ), | ||||
|                           ElevatedButton( | ||||
|                               onPressed: gettingAppInfo || | ||||
|                                       pickedSource == null || | ||||
|                                       (pickedSource!.additionalDataFormItems | ||||
|                                               .isNotEmpty && | ||||
|                                           !validAdditionalData) | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.selectionClick(); | ||||
|                                       setState(() { | ||||
|                                         gettingAppInfo = true; | ||||
|                                       }); | ||||
|                                       sourceProvider | ||||
|                                           .getApp(pickedSource!, userInput, | ||||
|                                               additionalData, | ||||
|                                               customName: customName) | ||||
|                                           .then((app) { | ||||
|                                         var appsProvider = | ||||
|                                             context.read<AppsProvider>(); | ||||
|                                         var settingsProvider = | ||||
|                                             context.read<SettingsProvider>(); | ||||
|                                         if (appsProvider.apps | ||||
|                                             .containsKey(app.id)) { | ||||
|                                           throw 'App already added'; | ||||
|                                         } | ||||
|                                         settingsProvider | ||||
|                                             .getInstallPermission() | ||||
|                                             .then((_) { | ||||
|                                           appsProvider | ||||
|                                               .saveApps([app]).then((_) { | ||||
|                           gettingAppInfo | ||||
|                               ? const CircularProgressIndicator() | ||||
|                               : ElevatedButton( | ||||
|                                   onPressed: gettingAppInfo || | ||||
|                                           pickedSource == null || | ||||
|                                           (pickedSource!.additionalDataFormItems | ||||
|                                                   .isNotEmpty && | ||||
|                                               !validAdditionalData) | ||||
|                                       ? null | ||||
|                                       : () async { | ||||
|                                           setState(() { | ||||
|                                             gettingAppInfo = true; | ||||
|                                           }); | ||||
|                                           var appsProvider = | ||||
|                                               context.read<AppsProvider>(); | ||||
|                                           var settingsProvider = | ||||
|                                               context.read<SettingsProvider>(); | ||||
|                                           () async { | ||||
|                                             HapticFeedback.selectionClick(); | ||||
|                                             App app = | ||||
|                                                 await sourceProvider.getApp( | ||||
|                                                     pickedSource!, | ||||
|                                                     userInput, | ||||
|                                                     additionalData); | ||||
|                                             await settingsProvider | ||||
|                                                 .getInstallPermission(); | ||||
|                                             // ignore: use_build_context_synchronously | ||||
|                                             var apkUrl = await appsProvider | ||||
|                                                 .selectApkUrl(app, context); | ||||
|                                             if (apkUrl == null) { | ||||
|                                               throw 'Cancelled'; | ||||
|                                             } | ||||
|                                             app.preferredApkIndex = | ||||
|                                                 app.apkUrls.indexOf(apkUrl); | ||||
|                                             var downloadedApk = | ||||
|                                                 await appsProvider.downloadApp( | ||||
|                                                     app, | ||||
|                                                     showOccasionalProgressToast: | ||||
|                                                         true); | ||||
|                                             app.id = downloadedApk.appId; | ||||
|                                             if (appsProvider.apps | ||||
|                                                 .containsKey(app.id)) { | ||||
|                                               throw 'App already added'; | ||||
|                                             } | ||||
|                                             await appsProvider.saveApps([app]); | ||||
|  | ||||
|                                             return app; | ||||
|                                           }() | ||||
|                                               .then((app) { | ||||
|                                             Navigator.push( | ||||
|                                                 context, | ||||
|                                                 MaterialPageRoute( | ||||
|                                                     builder: (context) => | ||||
|                                                         AppPage( | ||||
|                                                             appId: app.id))); | ||||
|                                           }).catchError((e) { | ||||
|                                             ScaffoldMessenger.of(context) | ||||
|                                                 .showSnackBar( | ||||
|                                               SnackBar( | ||||
|                                                   content: Text(e.toString())), | ||||
|                                             ); | ||||
|                                           }).whenComplete(() { | ||||
|                                             setState(() { | ||||
|                                               gettingAppInfo = false; | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }); | ||||
|                                       }).catchError((e) { | ||||
|                                         ScaffoldMessenger.of(context) | ||||
|                                             .showSnackBar( | ||||
|                                           SnackBar(content: Text(e.toString())), | ||||
|                                         ); | ||||
|                                       }).whenComplete(() { | ||||
|                                         setState(() { | ||||
|                                           gettingAppInfo = false; | ||||
|                                         }); | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               child: const Text('Add')) | ||||
|                                         }, | ||||
|                                   child: const Text('Add')) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (pickedSource != null) | ||||
|                       if (pickedSource != null && | ||||
|                           pickedSource!.additionalDataDefaults.isNotEmpty) | ||||
|                         Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                           children: [ | ||||
| @@ -174,21 +189,6 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               const SizedBox( | ||||
|                                 height: 8, | ||||
|                               ), | ||||
|                             if (pickedSource != null) | ||||
|                               GeneratedForm( | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: 'Custom App Name', | ||||
|                                           required: false) | ||||
|                                     ] | ||||
|                                   ], | ||||
|                                   onValueChanges: (values, valid) { | ||||
|                                     setState(() { | ||||
|                                       customName = values[0]; | ||||
|                                     }); | ||||
|                                   }, | ||||
|                                   defaultValues: [customName]) | ||||
|                           ], | ||||
|                         ) | ||||
|                       else | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| @@ -46,6 +45,7 @@ class _AppPageState extends State<AppPage> { | ||||
|       body: RefreshIndicator( | ||||
|           child: settingsProvider.showAppWebpage | ||||
|               ? WebView( | ||||
|                   backgroundColor: Theme.of(context).colorScheme.background, | ||||
|                   initialUrl: app?.app.url, | ||||
|                   javascriptMode: JavascriptMode.unrestricted, | ||||
|                 ) | ||||
| @@ -56,8 +56,22 @@ class _AppPageState extends State<AppPage> { | ||||
|                       mainAxisAlignment: MainAxisAlignment.center, | ||||
|                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                       children: [ | ||||
|                         app?.installedInfo != null | ||||
|                             ? Row( | ||||
|                                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                                 children: [ | ||||
|                                     Image.memory( | ||||
|                                       app!.installedInfo!.icon!, | ||||
|                                       height: 150, | ||||
|                                       gaplessPlayback: true, | ||||
|                                     ) | ||||
|                                   ]) | ||||
|                             : Container(), | ||||
|                         const SizedBox( | ||||
|                           height: 25, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           app?.app.name ?? 'App', | ||||
|                           app?.installedInfo?.name ?? app?.app.name ?? 'App', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: Theme.of(context).textTheme.displayLarge, | ||||
|                         ), | ||||
| @@ -126,7 +140,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                   child: Row( | ||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                       children: [ | ||||
|                         if (app?.app.installedVersion != app?.app.latestVersion) | ||||
|                         if (app?.app.installedVersion != null && | ||||
|                             app?.app.installedVersion != app?.app.latestVersion) | ||||
|                           IconButton( | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
| @@ -135,8 +150,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return AlertDialog( | ||||
|                                               title: Text( | ||||
|                                                   'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), | ||||
|                                               title: const Text( | ||||
|                                                   'App Already up to Date?'), | ||||
|                                               actions: [ | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
| @@ -161,54 +176,13 @@ class _AppPageState extends State<AppPage> { | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: const Text( | ||||
|                                                         'Yes, Mark as Installed')) | ||||
|                                                         'Yes, Mark as Updated')) | ||||
|                                               ], | ||||
|                                             ); | ||||
|                                           }); | ||||
|                                     }, | ||||
|                               tooltip: 'Mark as Installed', | ||||
|                               icon: const Icon(Icons.done)) | ||||
|                         else | ||||
|                           IconButton( | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       showDialog( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return AlertDialog( | ||||
|                                               title: const Text( | ||||
|                                                   'App Not Installed?'), | ||||
|                                               actions: [ | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: const Text('No')), | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       HapticFeedback | ||||
|                                                           .selectionClick(); | ||||
|                                                       var updatedApp = app?.app; | ||||
|                                                       if (updatedApp != null) { | ||||
|                                                         updatedApp | ||||
|                                                                 .installedVersion = | ||||
|                                                             null; | ||||
|                                                         appsProvider.saveApps( | ||||
|                                                             [updatedApp]); | ||||
|                                                       } | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: const Text( | ||||
|                                                         'Yes, Mark as Not Installed')) | ||||
|                                               ], | ||||
|                                             ); | ||||
|                                           }); | ||||
|                                     }, | ||||
|                               tooltip: 'Mark as Not Installed', | ||||
|                               icon: const Icon(Icons.no_cell_outlined)), | ||||
|                               tooltip: 'Mark as Updated', | ||||
|                               icon: const Icon(Icons.done)), | ||||
|                         if (source != null && | ||||
|                             source.additionalDataFormItems.isNotEmpty) | ||||
|                           IconButton( | ||||
| @@ -220,30 +194,15 @@ class _AppPageState extends State<AppPage> { | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return GeneratedFormModal( | ||||
|                                                 title: 'Additional Options', | ||||
|                                                 items: [ | ||||
|                                                   ...source | ||||
|                                                       .additionalDataFormItems, | ||||
|                                                   [ | ||||
|                                                     GeneratedFormItem( | ||||
|                                                         label: 'App Name', | ||||
|                                                         required: true) | ||||
|                                                   ] | ||||
|                                                 ], | ||||
|                                                 items: source | ||||
|                                                     .additionalDataFormItems, | ||||
|                                                 defaultValues: app != null | ||||
|                                                     ? [ | ||||
|                                                         ...app | ||||
|                                                             .app.additionalData, | ||||
|                                                         app.app.name | ||||
|                                                       ] | ||||
|                                                     : [ | ||||
|                                                         ...source | ||||
|                                                             .additionalDataDefaults | ||||
|                                                       ]); | ||||
|                                                     ? app.app.additionalData | ||||
|                                                     : source | ||||
|                                                         .additionalDataDefaults); | ||||
|                                           }).then((values) { | ||||
|                                         if (app != null && values != null) { | ||||
|                                           var changedApp = app.app; | ||||
|                                           var name = values.removeLast(); | ||||
|                                           changedApp.name = name; | ||||
|                                           changedApp.additionalData = values; | ||||
|                                           appsProvider.saveApps( | ||||
|                                               [changedApp]).then((value) { | ||||
| @@ -265,12 +224,18 @@ class _AppPageState extends State<AppPage> { | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
|                                         appsProvider | ||||
|                                             .downloadAndInstallLatestApp( | ||||
|                                             .downloadAndInstallLatestApps( | ||||
|                                                 [app!.app.id], | ||||
|                                                 context).then((res) { | ||||
|                                           if (res.isNotEmpty && mounted) { | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                           } | ||||
|                                         }).catchError((e) { | ||||
|                                           ScaffoldMessenger.of(context) | ||||
|                                               .showSnackBar( | ||||
|                                             SnackBar( | ||||
|                                                 content: Text(e.toString())), | ||||
|                                           ); | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
| @@ -288,7 +253,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                                         return AlertDialog( | ||||
|                                           title: const Text('Remove App?'), | ||||
|                                           content: Text( | ||||
|                                               'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), | ||||
|                                               'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), | ||||
|                                           actions: [ | ||||
|                                             TextButton( | ||||
|                                                 onPressed: () { | ||||
|   | ||||
| @@ -23,6 +23,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|   var updatesOnlyFilter = | ||||
|       AppsFilter(includeUptodate: false, includeNonInstalled: false); | ||||
|   Set<String> selectedIds = {}; | ||||
|   DateTime? refreshingSince; | ||||
|  | ||||
|   clearSelected() { | ||||
|     if (selectedIds.isNotEmpty) { | ||||
| @@ -89,7 +90,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|             .toList(); | ||||
|  | ||||
|         for (var t in nameTokens) { | ||||
|           if (!app.app.name.toLowerCase().contains(t.toLowerCase())) { | ||||
|           var name = app.installedInfo?.name ?? app.app.name; | ||||
|           if (!name.toLowerCase().contains(t.toLowerCase())) { | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
| @@ -103,23 +105,24 @@ class AppsPageState extends State<AppsPage> { | ||||
|     } | ||||
|  | ||||
|     sortedApps.sort((a, b) { | ||||
|       var nameA = a.installedInfo?.name ?? a.app.name; | ||||
|       var nameB = b.installedInfo?.name ?? b.app.name; | ||||
|       int result = 0; | ||||
|       if (settingsProvider.sortColumn == SortColumnSettings.authorName) { | ||||
|         result = | ||||
|             (a.app.author + a.app.name).compareTo(b.app.author + b.app.name); | ||||
|         result = (a.app.author + nameA).compareTo(b.app.author + nameB); | ||||
|       } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { | ||||
|         result = | ||||
|             (a.app.name + a.app.author).compareTo(b.app.name + b.app.author); | ||||
|         result = (nameA + a.app.author).compareTo(nameB + b.app.author); | ||||
|       } | ||||
|       return result; | ||||
|     }); | ||||
|  | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { | ||||
|     if (settingsProvider.sortOrder == SortOrderSettings.descending) { | ||||
|       sortedApps = sortedApps.reversed.toList(); | ||||
|     } | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = appsProvider | ||||
|         .getExistingUpdates(installedOnly: true) | ||||
|     var existingUpdates = appsProvider.getExistingUpdates(installedOnly: true); | ||||
|  | ||||
|     var existingUpdateIdsAllOrSelected = existingUpdates | ||||
|         .where((element) => selectedIds.isEmpty | ||||
|             ? sortedApps.where((a) => a.app.id == element).isNotEmpty | ||||
|             : selectedIds.contains(element)) | ||||
| @@ -131,15 +134,34 @@ class AppsPageState extends State<AppsPage> { | ||||
|             : selectedIds.contains(element)) | ||||
|         .toList(); | ||||
|  | ||||
|     if (settingsProvider.pinUpdates) { | ||||
|       var temp = []; | ||||
|       sortedApps = sortedApps.where((sa) { | ||||
|         if (existingUpdates.contains(sa.app.id)) { | ||||
|           temp.add(sa); | ||||
|           return false; | ||||
|         } | ||||
|         return true; | ||||
|       }).toList(); | ||||
|       sortedApps = [...temp, ...sortedApps]; | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
|           onRefresh: () { | ||||
|             HapticFeedback.lightImpact(); | ||||
|             setState(() { | ||||
|               refreshingSince = DateTime.now(); | ||||
|             }); | ||||
|             return appsProvider.checkUpdates().catchError((e) { | ||||
|               ScaffoldMessenger.of(context).showSnackBar( | ||||
|                 SnackBar(content: Text(e.toString())), | ||||
|               ); | ||||
|             }).whenComplete(() { | ||||
|               setState(() { | ||||
|                 refreshingSince = null; | ||||
|               }); | ||||
|             }); | ||||
|           }, | ||||
|           child: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -156,6 +178,17 @@ class AppsPageState extends State<AppsPage> { | ||||
|                               style: Theme.of(context).textTheme.headlineMedium, | ||||
|                               textAlign: TextAlign.center, | ||||
|                             ))), | ||||
|             if (refreshingSince != null) | ||||
|               SliverToBoxAdapter( | ||||
|                 child: LinearProgressIndicator( | ||||
|                   value: appsProvider.apps.values | ||||
|                           .where((element) => !(element.app.lastUpdateCheck | ||||
|                                   ?.isBefore(refreshingSince!) ?? | ||||
|                               true)) | ||||
|                           .length / | ||||
|                       appsProvider.apps.length, | ||||
|                 ), | ||||
|               ), | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
| @@ -166,7 +199,14 @@ class AppsPageState extends State<AppsPage> { | ||||
|                 onLongPress: () { | ||||
|                   toggleAppSelected(sortedApps[index].app.id); | ||||
|                 }, | ||||
|                 title: Text(sortedApps[index].app.name), | ||||
|                 leading: sortedApps[index].installedInfo != null | ||||
|                     ? Image.memory( | ||||
|                         sortedApps[index].installedInfo!.icon!, | ||||
|                         gaplessPlayback: true, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 title: Text(sortedApps[index].installedInfo?.name ?? | ||||
|                     sortedApps[index].app.name), | ||||
|                 subtitle: Text('By ${sortedApps[index].app.author}'), | ||||
|                 trailing: sortedApps[index].downloadProgress != null | ||||
|                     ? Text( | ||||
| @@ -178,7 +218,9 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             mainAxisAlignment: MainAxisAlignment.center, | ||||
|                             crossAxisAlignment: CrossAxisAlignment.end, | ||||
|                             children: [ | ||||
|                               const Text('Update Available'), | ||||
|                               Text(appsProvider.areDownloadsRunning() | ||||
|                                   ? 'Please Wait...' | ||||
|                                   : 'Update Available'), | ||||
|                               SourceProvider() | ||||
|                                           .getSource(sortedApps[index].app.url) | ||||
|                                           .changeLogPageFromStandardUrl( | ||||
| @@ -205,8 +247,15 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                       )), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : Text(sortedApps[index].app.installedVersion ?? | ||||
|                             'Not Installed')), | ||||
|                         : SingleChildScrollView( | ||||
|                             child: SizedBox( | ||||
|                                 width: 80, | ||||
|                                 child: Text( | ||||
|                                   sortedApps[index].app.installedVersion ?? | ||||
|                                       'Not Installed', | ||||
|                                   overflow: TextOverflow.fade, | ||||
|                                   textAlign: TextAlign.end, | ||||
|                                 )))), | ||||
|                 onTap: () { | ||||
|                   if (selectedIds.isNotEmpty) { | ||||
|                     toggleAppSelected(sortedApps[index].app.id); | ||||
| @@ -303,15 +352,20 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                     message: | ||||
|                                         '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', | ||||
|                                     items: formInputs, | ||||
|                                     defaultValues: const ['true', 'true'], | ||||
|                                     defaultValues: [ | ||||
|                                       'true', | ||||
|                                       existingUpdateIdsAllOrSelected.isEmpty | ||||
|                                           ? 'true' | ||||
|                                           : '' | ||||
|                                     ], | ||||
|                                     initValid: true, | ||||
|                                   ); | ||||
|                                 }).then((values) { | ||||
|                               if (values != null) { | ||||
|                                 bool shouldInstallUpdates = | ||||
|                                     values.length < 2 || values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = | ||||
|                                     values.length < 2 || values[1] == 'true'; | ||||
|                                     values.isEmpty || values[0] == 'true'; | ||||
|                                 bool shouldInstallNew = values.isEmpty || | ||||
|                                     (values.length >= 2 && values[1] == 'true'); | ||||
|                                 settingsProvider | ||||
|                                     .getInstallPermission() | ||||
|                                     .then((_) { | ||||
| @@ -324,8 +378,14 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                     toInstall | ||||
|                                         .addAll(newInstallIdsAllOrSelected); | ||||
|                                   } | ||||
|                                   appsProvider.downloadAndInstallLatestApp( | ||||
|                                       toInstall, context); | ||||
|                                   appsProvider | ||||
|                                       .downloadAndInstallLatestApps( | ||||
|                                           toInstall, context) | ||||
|                                       .catchError((e) { | ||||
|                                     ScaffoldMessenger.of(context).showSnackBar( | ||||
|                                       SnackBar(content: Text(e.toString())), | ||||
|                                     ); | ||||
|                                   }); | ||||
|                                 }); | ||||
|                               } | ||||
|                             }); | ||||
| @@ -349,7 +409,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                     padding: const EdgeInsets.only(top: 6), | ||||
|                                     child: Row( | ||||
|                                         mainAxisAlignment: | ||||
|                                             MainAxisAlignment.spaceBetween, | ||||
|                                             MainAxisAlignment.spaceAround, | ||||
|                                         children: [ | ||||
|                                           IconButton( | ||||
|                                               onPressed: | ||||
| @@ -364,7 +424,10 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                       ctx) { | ||||
|                                                                 return AlertDialog( | ||||
|                                                                   title: Text( | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Not Installed?'), | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Updated?'), | ||||
|                                                                   content: | ||||
|                                                                       const Text( | ||||
|                                                                           'Only applies to installed but out of date Apps.'), | ||||
|                                                                   actions: [ | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
| @@ -383,8 +446,10 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                               .saveApps(selectedIds.map((e) { | ||||
|                                                                             var a = | ||||
|                                                                                 appsProvider.apps[e]!.app; | ||||
|                                                                             a.installedVersion = | ||||
|                                                                                 null; | ||||
|                                                                             if (a.installedVersion != | ||||
|                                                                                 null) { | ||||
|                                                                               a.installedVersion = a.latestVersion; | ||||
|                                                                             } | ||||
|                                                                             return a; | ||||
|                                                                           }).toList()); | ||||
|  | ||||
| @@ -398,57 +463,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                               }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Not Installed', | ||||
|                                               icon: const Icon( | ||||
|                                                   Icons.no_cell_outlined)), | ||||
|                                           IconButton( | ||||
|                                               onPressed: | ||||
|                                                   appsProvider | ||||
|                                                           .areDownloadsRunning() | ||||
|                                                       ? null | ||||
|                                                       : () { | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return AlertDialog( | ||||
|                                                                   title: Text( | ||||
|                                                                       'Mark ${selectedIds.length} Selected Apps as Installed/Updated?'), | ||||
|                                                                   actions: [ | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'No')), | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
|                                                                           HapticFeedback | ||||
|                                                                               .selectionClick(); | ||||
|                                                                           appsProvider | ||||
|                                                                               .saveApps(selectedIds.map((e) { | ||||
|                                                                             var a = | ||||
|                                                                                 appsProvider.apps[e]!.app; | ||||
|                                                                             a.installedVersion = | ||||
|                                                                                 a.latestVersion; | ||||
|                                                                             return a; | ||||
|                                                                           }).toList()); | ||||
|  | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'Yes')) | ||||
|                                                                   ], | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Installed/Updated', | ||||
|                                                   'Mark Selected Apps as Updated', | ||||
|                                               icon: const Icon(Icons.done)), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
|   | ||||
| @@ -155,6 +155,20 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const SizedBox( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 const Text('Pin Updates to Top of Apps View'), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.pinUpdates, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.pinUpdates = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|   | ||||
| @@ -8,9 +8,14 @@ import 'dart:io'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||
| import 'package:installed_apps/app_info.dart'; | ||||
| import 'package:installed_apps/installed_apps.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:package_archive_info/package_archive_info.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:flutter_fgbg/flutter_fgbg.dart'; | ||||
| @@ -20,14 +25,15 @@ import 'package:http/http.dart'; | ||||
| class AppInMemory { | ||||
|   late App app; | ||||
|   double? downloadProgress; | ||||
|   AppInfo? installedInfo; // Also indicates that an App is installed | ||||
|  | ||||
|   AppInMemory(this.app, this.downloadProgress); | ||||
|   AppInMemory(this.app, this.downloadProgress, this.installedInfo); | ||||
| } | ||||
|  | ||||
| class ApkFile { | ||||
| class DownloadedApp { | ||||
|   String appId; | ||||
|   File file; | ||||
|   ApkFile(this.appId, this.file); | ||||
|   DownloadedApp(this.appId, this.file); | ||||
| } | ||||
|  | ||||
| class AppsProvider with ChangeNotifier { | ||||
| @@ -38,24 +44,24 @@ class AppsProvider with ChangeNotifier { | ||||
|  | ||||
|   // Variables to keep track of the app foreground status (installs can't run in the background) | ||||
|   bool isForeground = true; | ||||
|   late Stream<FGBGType> foregroundStream; | ||||
|   late StreamSubscription<FGBGType> foregroundSubscription; | ||||
|   late Stream<FGBGType>? foregroundStream; | ||||
|   late StreamSubscription<FGBGType>? foregroundSubscription; | ||||
|  | ||||
|   AppsProvider( | ||||
|       {bool shouldLoadApps = false, | ||||
|       bool shouldCheckUpdatesAfterLoad = false, | ||||
|       bool shouldDeleteAPKs = false}) { | ||||
|     // Subscribe to changes in the app foreground status | ||||
|     foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||
|     foregroundSubscription = foregroundStream.listen((event) async { | ||||
|       isForeground = event == FGBGType.foreground; | ||||
|       if (isForeground) await loadApps(); | ||||
|     }); | ||||
|     if (shouldDeleteAPKs) { | ||||
|       deleteSavedAPKs(); | ||||
|     } | ||||
|     if (shouldLoadApps) { | ||||
|       // Subscribe to changes in the app foreground status | ||||
|       foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||
|       foregroundSubscription = foregroundStream?.listen((event) async { | ||||
|         isForeground = event == FGBGType.foreground; | ||||
|         if (isForeground) await loadApps(); | ||||
|       }); | ||||
|       loadApps().then((_) { | ||||
|         if (shouldDeleteAPKs) { | ||||
|           deleteSavedAPKs(); | ||||
|         } | ||||
|         if (shouldCheckUpdatesAfterLoad) { | ||||
|           checkUpdates(); | ||||
|         } | ||||
| @@ -63,38 +69,95 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<ApkFile> downloadApp(String apkUrl, String appId) async { | ||||
|     apkUrl = await SourceProvider() | ||||
|         .getSource(apps[appId]!.app.url) | ||||
|         .apkUrlPrefetchModifier(apkUrl); | ||||
|   downloadApk(String apkUrl, String fileName, Function? onProgress, | ||||
|       Function? urlModifier, | ||||
|       {bool useExistingIfExists = true}) async { | ||||
|     var destDir = (await getExternalStorageDirectory())!.path; | ||||
|     if (urlModifier != null) { | ||||
|       apkUrl = await urlModifier(apkUrl); | ||||
|     } | ||||
|     StreamedResponse response = | ||||
|         await Client().send(Request('GET', Uri.parse(apkUrl))); | ||||
|     File downloadFile = | ||||
|         File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); | ||||
|     if (downloadFile.existsSync()) { | ||||
|       downloadFile.deleteSync(); | ||||
|     } | ||||
|     var length = response.contentLength; | ||||
|     var received = 0; | ||||
|     var sink = downloadFile.openWrite(); | ||||
|     File downloadFile = File('$destDir/$fileName.apk'); | ||||
|     var alreadyExists = downloadFile.existsSync(); | ||||
|     if (!alreadyExists || !useExistingIfExists) { | ||||
|       if (alreadyExists) { | ||||
|         downloadFile.deleteSync(); | ||||
|       } | ||||
|  | ||||
|     await response.stream.map((s) { | ||||
|       received += s.length; | ||||
|       apps[appId]!.downloadProgress = | ||||
|           (length != null ? received / length * 100 : 30); | ||||
|       var length = response.contentLength; | ||||
|       var received = 0; | ||||
|       double? progress; | ||||
|       var sink = downloadFile.openWrite(); | ||||
|  | ||||
|       await response.stream.map((s) { | ||||
|         received += s.length; | ||||
|         progress = (length != null ? received / length * 100 : 30); | ||||
|         if (onProgress != null) { | ||||
|           onProgress(progress); | ||||
|         } | ||||
|         return s; | ||||
|       }).pipe(sink); | ||||
|  | ||||
|       await sink.close(); | ||||
|       progress = null; | ||||
|       if (onProgress != null) { | ||||
|         onProgress(progress); | ||||
|       } | ||||
|  | ||||
|       if (response.statusCode != 200) { | ||||
|         downloadFile.deleteSync(); | ||||
|         throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|       } | ||||
|     } | ||||
|     return downloadFile; | ||||
|   } | ||||
|  | ||||
|   // Downloads the App (preferred URL) and returns an ApkFile object | ||||
|   // If the app was already saved, updates it's download progress % in memory | ||||
|   // But also works for Apps that are not saved | ||||
|   Future<DownloadedApp> downloadApp(App app, | ||||
|       {bool showOccasionalProgressToast = false}) async { | ||||
|     int? prevProg; | ||||
|     var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}'; | ||||
|     File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex], | ||||
|         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}', | ||||
|         (double? progress) { | ||||
|       if (apps[app.id] != null) { | ||||
|         apps[app.id]!.downloadProgress = progress; | ||||
|       } | ||||
|       int? prog = progress?.ceil(); | ||||
|       if (showOccasionalProgressToast && | ||||
|           (prog == 25 || prog == 50 || prog == 75) && | ||||
|           prevProg != prog) { | ||||
|         Fluttertoast.showToast( | ||||
|             msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT); | ||||
|       } | ||||
|       prevProg = prog; | ||||
|       notifyListeners(); | ||||
|       return s; | ||||
|     }).pipe(sink); | ||||
|  | ||||
|     await sink.close(); | ||||
|     apps[appId]!.downloadProgress = null; | ||||
|     notifyListeners(); | ||||
|  | ||||
|     if (response.statusCode != 200) { | ||||
|       downloadFile.deleteSync(); | ||||
|       throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|     }, SourceProvider().getSource(app.url).apkUrlPrefetchModifier); | ||||
|     // Delete older versions of the APK if any | ||||
|     for (var file in downloadFile.parent.listSync()) { | ||||
|       var fn = file.path.split('/').last; | ||||
|       if (fn.startsWith('${app.id}-') && | ||||
|           fn.endsWith('.apk') && | ||||
|           fn != '$fileName.apk') { | ||||
|         file.delete(); | ||||
|       } | ||||
|     } | ||||
|     return ApkFile(appId, downloadFile); | ||||
|     // If the ID has changed (as it should on first download), replace it | ||||
|     var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path); | ||||
|     if (app.id != newInfo.packageName) { | ||||
|       var originalAppId = app.id; | ||||
|       app.id = newInfo.packageName; | ||||
|       downloadFile = downloadFile.renameSync( | ||||
|           '${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'); | ||||
|       if (apps[originalAppId] != null) { | ||||
|         await removeApps([originalAppId]); | ||||
|         await saveApps([app]); | ||||
|       } | ||||
|     } | ||||
|     return DownloadedApp(app.id, downloadFile); | ||||
|   } | ||||
|  | ||||
|   bool areDownloadsRunning() => apps.values | ||||
| @@ -105,8 +168,8 @@ class AppsProvider with ChangeNotifier { | ||||
|     // TODO: This is unreliable - try to get from OS in the future | ||||
|     var osInfo = await DeviceInfoPlugin().androidInfo; | ||||
|     return app.installedVersion != null && | ||||
|         osInfo.version.sdkInt! >= 30 && | ||||
|         osInfo.version.release!.compareTo('12') >= 0; | ||||
|         osInfo.version.sdkInt >= 30 && | ||||
|         osInfo.version.release.compareTo('12') >= 0; | ||||
|   } | ||||
|  | ||||
|   Future<void> askUserToReturnToForeground(BuildContext context, | ||||
| @@ -127,11 +190,62 @@ class AppsProvider with ChangeNotifier { | ||||
|   // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing | ||||
|   // If appropriate criteria are met, the update (never a fresh install) happens silently  in the background | ||||
|   // But even then, we don't know if it actually succeeded | ||||
|   Future<void> installApk(ApkFile file) async { | ||||
|     await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium'); | ||||
|   Future<void> installApk(DownloadedApp file) async { | ||||
|     var newInfo = await PackageArchiveInfo.fromPath(file.file.path); | ||||
|     AppInfo? appInfo; | ||||
|     try { | ||||
|       appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id); | ||||
|     } catch (e) { | ||||
|       // OK | ||||
|     } | ||||
|     if (appInfo != null && | ||||
|         int.parse(newInfo.buildNumber) < appInfo.versionCode!) { | ||||
|       throw 'Can\'t install an older version'; | ||||
|     } | ||||
|     if (appInfo == null || | ||||
|         int.parse(newInfo.buildNumber) > appInfo.versionCode!) { | ||||
|       await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium'); | ||||
|     } | ||||
|     apps[file.appId]!.app.installedVersion = | ||||
|         apps[file.appId]!.app.latestVersion; | ||||
|     await saveApps([apps[file.appId]!.app]); | ||||
|     // Don't correct install status as installation may not be done yet | ||||
|     await saveApps([apps[file.appId]!.app], shouldCorrectInstallStatus: false); | ||||
|   } | ||||
|  | ||||
|   Future<String?> selectApkUrl(App app, BuildContext? context) async { | ||||
|     // If the App has more than one APK, the user should pick one (if context provided) | ||||
|     String? apkUrl = app.apkUrls[app.preferredApkIndex]; | ||||
|     if (app.apkUrls.length > 1 && context != null) { | ||||
|       apkUrl = await showDialog( | ||||
|           context: context, | ||||
|           builder: (BuildContext ctx) { | ||||
|             return APKPicker(app: app, initVal: apkUrl); | ||||
|           }); | ||||
|     } | ||||
|     // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) | ||||
|     if (apkUrl != null && | ||||
|         Uri.parse(apkUrl).origin != Uri.parse(app.url).origin && | ||||
|         context != null) { | ||||
|       if (await showDialog( | ||||
|               context: context, | ||||
|               builder: (BuildContext ctx) { | ||||
|                 return APKOriginWarningDialog( | ||||
|                     sourceUrl: app.url, apkUrl: apkUrl!); | ||||
|               }) != | ||||
|           true) { | ||||
|         apkUrl = null; | ||||
|       } | ||||
|     } | ||||
|     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 | ||||
| @@ -139,37 +253,16 @@ class AppsProvider with ChangeNotifier { | ||||
|   // If no BuildContext is provided, apps that require user interaction are ignored | ||||
|   // If user input is needed and the App is in the background, a notification is sent to get the user's attention | ||||
|   // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result | ||||
|   Future<List<String>> downloadAndInstallLatestApp( | ||||
|   Future<List<String>> downloadAndInstallLatestApps( | ||||
|       List<String> appIds, BuildContext? context) async { | ||||
|     Map<String, String> appsToInstall = {}; | ||||
|     List<String> appsToInstall = []; | ||||
|     for (var id in appIds) { | ||||
|       if (apps[id] == null) { | ||||
|         throw 'App not found'; | ||||
|       } | ||||
|  | ||||
|       // If the App has more than one APK, the user should pick one (if context provided) | ||||
|       String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; | ||||
|       if (apps[id]!.app.apkUrls.length > 1 && context != null) { | ||||
|         apkUrl = await showDialog( | ||||
|             context: context, | ||||
|             builder: (BuildContext ctx) { | ||||
|               return APKPicker(app: apps[id]!.app, initVal: apkUrl); | ||||
|             }); | ||||
|       } | ||||
|       // If the picked APK comes from an origin different from the source, get user confirmation (if context provided) | ||||
|       if (apkUrl != null && | ||||
|           Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin && | ||||
|           context != null) { | ||||
|         if (await showDialog( | ||||
|                 context: context, | ||||
|                 builder: (BuildContext ctx) { | ||||
|                   return APKOriginWarningDialog( | ||||
|                       sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!); | ||||
|                 }) != | ||||
|             true) { | ||||
|           apkUrl = null; | ||||
|         } | ||||
|       } | ||||
|       String? apkUrl = await selectApkUrl(apps[id]!.app, context); | ||||
|  | ||||
|       if (apkUrl != null) { | ||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||
| @@ -179,18 +272,28 @@ class AppsProvider with ChangeNotifier { | ||||
|         if (context != null || | ||||
|             (await canInstallSilently(apps[id]!.app) && | ||||
|                 apps[id]!.app.apkUrls.length == 1)) { | ||||
|           appsToInstall.putIfAbsent(id, () => apkUrl!); | ||||
|           appsToInstall.add(id); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     Map<String, List<String>> errors = {}; | ||||
|  | ||||
|     List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries | ||||
|         .map((entry) => downloadApp(entry.value, entry.key))); | ||||
|     List<DownloadedApp?> downloadedFiles = | ||||
|         await Future.wait(appsToInstall.map((id) async { | ||||
|       try { | ||||
|         return await downloadApp(apps[id]!.app); | ||||
|       } catch (e) { | ||||
|         addToErrorMap(errors, id, e.toString()); | ||||
|       } | ||||
|       return null; | ||||
|     })); | ||||
|     downloadedFiles = | ||||
|         downloadedFiles.where((element) => element != null).toList(); | ||||
|  | ||||
|     List<ApkFile> silentUpdates = []; | ||||
|     List<ApkFile> regularInstalls = []; | ||||
|     List<DownloadedApp> silentUpdates = []; | ||||
|     List<DownloadedApp> regularInstalls = []; | ||||
|     for (var f in downloadedFiles) { | ||||
|       bool willBeSilent = await canInstallSilently(apps[f.appId]!.app); | ||||
|       bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app); | ||||
|       if (willBeSilent) { | ||||
|         silentUpdates.add(f); | ||||
|       } else { | ||||
| @@ -199,9 +302,9 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|  | ||||
|     // If Obtainium is being installed, it should be the last one | ||||
|     List<ApkFile> moveObtainiumToEnd(List<ApkFile> items) { | ||||
|     List<DownloadedApp> moveObtainiumToStart(List<DownloadedApp> items) { | ||||
|       String obtainiumId = 'imranr98_obtainium_${GitHub().host}'; | ||||
|       ApkFile? temp; | ||||
|       DownloadedApp? temp; | ||||
|       items.removeWhere((element) { | ||||
|         bool res = element.appId == obtainiumId; | ||||
|         if (res) { | ||||
| @@ -210,7 +313,7 @@ class AppsProvider with ChangeNotifier { | ||||
|         return res; | ||||
|       }); | ||||
|       if (temp != null) { | ||||
|         items.add(temp!); | ||||
|         items = [temp!, ...items]; | ||||
|       } | ||||
|       return items; | ||||
|     } | ||||
| @@ -218,8 +321,8 @@ class AppsProvider with ChangeNotifier { | ||||
|     // TODO: Remove below line if silentupdates are ever figured out | ||||
|     regularInstalls.addAll(silentUpdates); | ||||
|  | ||||
|     silentUpdates = moveObtainiumToEnd(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToEnd(regularInstalls); | ||||
|     silentUpdates = moveObtainiumToStart(silentUpdates); | ||||
|     regularInstalls = moveObtainiumToStart(regularInstalls); | ||||
|  | ||||
|     // TODO: Uncomment below if silentupdates are ever figured out | ||||
|     // for (var u in silentUpdates) { | ||||
| @@ -232,11 +335,23 @@ class AppsProvider with ChangeNotifier { | ||||
|         await askUserToReturnToForeground(context, waitForFG: true); | ||||
|       } | ||||
|       for (var i in regularInstalls) { | ||||
|         await installApk(i); | ||||
|         try { | ||||
|           await installApk(i); | ||||
|         } catch (e) { | ||||
|           addToErrorMap(errors, i.appId, e.toString()); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     if (errors.isNotEmpty) { | ||||
|       String finalError = ''; | ||||
|       for (var e in errors.keys) { | ||||
|         finalError += | ||||
|             '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; | ||||
|       } | ||||
|       throw finalError; | ||||
|     } | ||||
|  | ||||
|     return downloadedFiles.map((e) => e.appId).toList(); | ||||
|     return downloadedFiles.map((e) => e!.appId).toList(); | ||||
|   } | ||||
|  | ||||
|   Future<Directory> getAppsDir() async { | ||||
| @@ -248,16 +363,83 @@ class AppsProvider with ChangeNotifier { | ||||
|     return appsDir; | ||||
|   } | ||||
|  | ||||
|   // Delete all stored APKs except those likely to still be needed | ||||
|   Future<void> deleteSavedAPKs() async { | ||||
|     (await getExternalStorageDirectory()) | ||||
|     List<FileSystemEntity>? apks = (await getExternalStorageDirectory()) | ||||
|         ?.listSync() | ||||
|         .where((element) => element.path.endsWith('.apk')) | ||||
|         .forEach((element) { | ||||
|       element.deleteSync(); | ||||
|     }); | ||||
|         .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<void> loadApps() async { | ||||
|   Future<AppInfo?> getInstalledInfo(String? packageName) async { | ||||
|     if (packageName != null) { | ||||
|       try { | ||||
|         return await InstalledApps.getAppInfo(packageName); | ||||
|       } catch (e) { | ||||
|         // OK | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   String standardizeVersionString(String versionString) { | ||||
|     return versionString.characters | ||||
|         .where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.'] | ||||
|             .contains(p0)) | ||||
|         .join(''); | ||||
|   } | ||||
|  | ||||
|   // If the App says it is installed by installedInfo is null, set it to not installed | ||||
|   // If the App says is is not installed but installedInfo exists, try to set it to installed as latest version... | ||||
|   // ...if the latestVersion seems to match the version in installedInfo (not guaranteed) | ||||
|   App? correctInstallStatus(App app, AppInfo? installedInfo) { | ||||
|     var modded = false; | ||||
|     if (installedInfo == null && app.installedVersion != null) { | ||||
|       app.installedVersion = null; | ||||
|       modded = true; | ||||
|     } | ||||
|     if (installedInfo != null && app.installedVersion == null) { | ||||
|       if (standardizeVersionString(app.latestVersion) == | ||||
|           installedInfo.versionName) { | ||||
|         app.installedVersion = app.latestVersion; | ||||
|       } else { | ||||
|         app.installedVersion = installedInfo.versionName; | ||||
|       } | ||||
|       modded = true; | ||||
|     } | ||||
|     return modded ? app : null; | ||||
|   } | ||||
|  | ||||
|   Future<void> loadApps({shouldCorrectInstallStatus = true}) async { | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
|     } | ||||
|     loadingApps = true; | ||||
|     notifyListeners(); | ||||
|     List<FileSystemEntity> appFiles = (await getAppsDir()) | ||||
| @@ -265,22 +447,54 @@ class AppsProvider with ChangeNotifier { | ||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||
|         .toList(); | ||||
|     apps.clear(); | ||||
|     var sp = SourceProvider(); | ||||
|     List<List<String>> errors = []; | ||||
|     for (int i = 0; i < appFiles.length; i++) { | ||||
|       App app = | ||||
|           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); | ||||
|       apps.putIfAbsent(app.id, () => AppInMemory(app, null)); | ||||
|       var info = await getInstalledInfo(app.id); | ||||
|       try { | ||||
|         sp.getSource(app.url); | ||||
|         apps.putIfAbsent(app.id, () => AppInMemory(app, null, info)); | ||||
|       } catch (e) { | ||||
|         errors.add([app.id, app.name, e.toString()]); | ||||
|       } | ||||
|     } | ||||
|     if (errors.isNotEmpty) { | ||||
|       removeApps(errors.map((e) => e[0]).toList()); | ||||
|       NotificationsProvider().notify( | ||||
|           AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList())); | ||||
|     } | ||||
|     loadingApps = false; | ||||
|     notifyListeners(); | ||||
|     // For any that are not installed (by ID == package name), set to not installed if needed | ||||
|     if (shouldCorrectInstallStatus) { | ||||
|       List<App> modifiedApps = []; | ||||
|       for (var app in apps.values) { | ||||
|         var moddedApp = correctInstallStatus(app.app, app.installedInfo); | ||||
|         if (moddedApp != null) { | ||||
|           modifiedApps.add(moddedApp); | ||||
|         } | ||||
|       } | ||||
|       if (modifiedApps.isNotEmpty) { | ||||
|         await saveApps(modifiedApps, shouldCorrectInstallStatus: false); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<void> saveApps(List<App> apps) async { | ||||
|   Future<void> saveApps(List<App> apps, | ||||
|       {bool shouldCorrectInstallStatus = true}) async { | ||||
|     for (var app in apps) { | ||||
|       AppInfo? info = await getInstalledInfo(app.id); | ||||
|       app.name = info?.name ?? app.name; | ||||
|       if (shouldCorrectInstallStatus) { | ||||
|         app = correctInstallStatus(app, info) ?? app; | ||||
|       } | ||||
|       File('${(await getAppsDir()).path}/${app.id}.json') | ||||
|           .writeAsStringSync(jsonEncode(app.toJson())); | ||||
|       this.apps.update( | ||||
|           app.id, (value) => AppInMemory(app, value.downloadProgress), | ||||
|           ifAbsent: () => AppInMemory(app, null)); | ||||
|           app.id, (value) => AppInMemory(app, value.downloadProgress, info), | ||||
|           ifAbsent: () => AppInMemory(app, null, info)); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
| @@ -307,45 +521,81 @@ class AppsProvider with ChangeNotifier { | ||||
|     return app.latestVersion != apps[app.id]?.app.installedVersion; | ||||
|   } | ||||
|  | ||||
|   Future<App?> getUpdate(String appId) async { | ||||
|   Future<App?> getUpdate(String appId, | ||||
|       {bool shouldCorrectInstallStatus = true}) async { | ||||
|     App? currentApp = apps[appId]!.app; | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     App newApp = await sourceProvider.getApp( | ||||
|         sourceProvider.getSource(currentApp.url), | ||||
|         currentApp.url, | ||||
|         currentApp.additionalData); | ||||
|         currentApp.additionalData, | ||||
|         name: currentApp.name, | ||||
|         id: currentApp.id); | ||||
|     newApp.installedVersion = currentApp.installedVersion; | ||||
|     if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|       newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|     } | ||||
|     await saveApps([newApp]); | ||||
|     await saveApps([newApp], | ||||
|         shouldCorrectInstallStatus: shouldCorrectInstallStatus); | ||||
|     return newApp.latestVersion != currentApp.latestVersion ? newApp : null; | ||||
|   } | ||||
|  | ||||
|   Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async { | ||||
|   Future<List<App>> checkUpdates( | ||||
|       {DateTime? ignoreAfter, | ||||
|       bool immediatelyThrowRateLimitError = false, | ||||
|       bool shouldCorrectInstallStatus = true, | ||||
|       bool immediatelyThrowSocketError = false}) async { | ||||
|     List<App> updates = []; | ||||
|     Map<String, List<String>> errors = {}; | ||||
|     if (!gettingUpdates) { | ||||
|       gettingUpdates = true; | ||||
|  | ||||
|       List<String> appIds = apps.keys.toList(); | ||||
|       if (ignoreAfter != null) { | ||||
|         appIds = appIds | ||||
|             .where((id) => | ||||
|                 apps[id]!.app.lastUpdateCheck == null || | ||||
|                 apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) | ||||
|             .toList(); | ||||
|       } | ||||
|       appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? | ||||
|               DateTime.fromMicrosecondsSinceEpoch(0)) | ||||
|           .compareTo(apps[b]!.app.lastUpdateCheck ?? | ||||
|               DateTime.fromMicrosecondsSinceEpoch(0))); | ||||
|       for (int i = 0; i < appIds.length; i++) { | ||||
|         App? newApp = await getUpdate(appIds[i]); | ||||
|         if (newApp != null) { | ||||
|           updates.add(newApp); | ||||
|       try { | ||||
|         List<String> appIds = apps.keys.toList(); | ||||
|         if (ignoreAfter != null) { | ||||
|           appIds = appIds | ||||
|               .where((id) => | ||||
|                   apps[id]!.app.lastUpdateCheck == null || | ||||
|                   apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter)) | ||||
|               .toList(); | ||||
|         } | ||||
|         appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ?? | ||||
|                 DateTime.fromMicrosecondsSinceEpoch(0)) | ||||
|             .compareTo(apps[b]!.app.lastUpdateCheck ?? | ||||
|                 DateTime.fromMicrosecondsSinceEpoch(0))); | ||||
|  | ||||
|         for (int i = 0; i < appIds.length; i++) { | ||||
|           App? newApp; | ||||
|           try { | ||||
|             newApp = await getUpdate(appIds[i], | ||||
|                 shouldCorrectInstallStatus: shouldCorrectInstallStatus); | ||||
|           } catch (e) { | ||||
|             if (e is RateLimitError && immediatelyThrowRateLimitError) { | ||||
|               rethrow; | ||||
|             } | ||||
|             if (e is SocketException && immediatelyThrowSocketError) { | ||||
|               rethrow; | ||||
|             } | ||||
|             var tempIds = errors.remove(e.toString()); | ||||
|             tempIds ??= []; | ||||
|             tempIds.add(appIds[i]); | ||||
|             errors.putIfAbsent(e.toString(), () => tempIds!); | ||||
|           } | ||||
|           if (newApp != null) { | ||||
|             updates.add(newApp); | ||||
|           } | ||||
|         } | ||||
|       } finally { | ||||
|         gettingUpdates = false; | ||||
|       } | ||||
|       gettingUpdates = false; | ||||
|     } | ||||
|     if (errors.isNotEmpty) { | ||||
|       String finalError = ''; | ||||
|       for (var e in errors.keys) { | ||||
|         finalError += | ||||
|             '$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. '; | ||||
|       } | ||||
|       throw finalError; | ||||
|     } | ||||
|     return updates; | ||||
|   } | ||||
| @@ -388,18 +638,22 @@ class AppsProvider with ChangeNotifier { | ||||
|     List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>) | ||||
|         .map((e) => App.fromJson(e)) | ||||
|         .toList(); | ||||
|     for (App a in importedApps) { | ||||
|       a.installedVersion = | ||||
|           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; | ||||
|       await saveApps([a]); | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
|     } | ||||
|     for (App a in importedApps) { | ||||
|       if (apps[a.id]?.app.installedVersion != null) { | ||||
|         a.installedVersion = apps[a.id]?.app.installedVersion; | ||||
|       } | ||||
|     } | ||||
|     await saveApps(importedApps); | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     foregroundSubscription.cancel(); | ||||
|     foregroundSubscription?.cancel(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -61,6 +61,24 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification { | ||||
|             Importance.high); | ||||
| } | ||||
|  | ||||
| class AppsRemovedNotification extends ObtainiumNotification { | ||||
|   AppsRemovedNotification(List<List<String>> namedReasons) | ||||
|       : super( | ||||
|             6, | ||||
|             'Apps Removed', | ||||
|             '', | ||||
|             'APPS_REMOVED', | ||||
|             'Apps Removed', | ||||
|             'Notifies the user that one or more Apps were removed due to errors while loading them', | ||||
|             Importance.max) { | ||||
|     message = ''; | ||||
|     for (var r in namedReasons) { | ||||
|       message += '${r[0]} was removed due to this error: ${r[1]}. \n'; | ||||
|     } | ||||
|     message = message.trim(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final completeInstallationNotification = ObtainiumNotification( | ||||
|     1, | ||||
|     'Complete App Installation', | ||||
|   | ||||
| @@ -74,8 +74,8 @@ class SettingsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   SortColumnSettings get sortColumn { | ||||
|     return SortColumnSettings | ||||
|         .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; | ||||
|     return SortColumnSettings.values[ | ||||
|         prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; | ||||
|   } | ||||
|  | ||||
|   set sortColumn(SortColumnSettings s) { | ||||
| @@ -85,7 +85,7 @@ class SettingsProvider with ChangeNotifier { | ||||
|  | ||||
|   SortOrderSettings get sortOrder { | ||||
|     return SortOrderSettings.values[ | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; | ||||
|         prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; | ||||
|   } | ||||
|  | ||||
|   set sortOrder(SortOrderSettings s) { | ||||
| @@ -123,6 +123,15 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get pinUpdates { | ||||
|     return prefs?.getBool('pinUpdates') ?? true; | ||||
|   } | ||||
|  | ||||
|   set pinUpdates(bool show) { | ||||
|     prefs?.setBool('pinUpdates', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|   } | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:obtainium/app_sources/apkmirror.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/app_sources/gitlab.dart'; | ||||
| @@ -54,7 +53,7 @@ class App { | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls'; | ||||
|     return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()}'; | ||||
|   } | ||||
|  | ||||
|   factory App.fromJson(Map<String, dynamic> json) => App( | ||||
| @@ -105,6 +104,11 @@ preStandardizeUrl(String url) { | ||||
|   if (url.toLowerCase().indexOf('https://www.') == 0) { | ||||
|     url = 'https://${url.substring(12)}'; | ||||
|   } | ||||
|   url = url | ||||
|       .split('/') | ||||
|       .where((e) => e.isNotEmpty) | ||||
|       .join('/') | ||||
|       .replaceFirst(':/', '://'); | ||||
|   return url; | ||||
| } | ||||
|  | ||||
| @@ -157,7 +161,7 @@ class SourceProvider { | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     SourceForge(), | ||||
|     APKMirror() | ||||
|     // APKMirror() | ||||
|   ]; | ||||
|  | ||||
|   // Add more mass source classes here so they are available via the service | ||||
| @@ -189,21 +193,24 @@ class SourceProvider { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   String generateTempID(AppNames names, AppSource source) => | ||||
|       '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}'; | ||||
|  | ||||
|   Future<App> getApp(AppSource source, String url, List<String> additionalData, | ||||
|       {String customName = ''}) async { | ||||
|       {String name = '', String? id}) async { | ||||
|     String standardUrl = source.standardizeURL(preStandardizeUrl(url)); | ||||
|     AppNames names = source.getAppNames(standardUrl); | ||||
|     APKDetails apk = | ||||
|         await source.getLatestAPKDetails(standardUrl, additionalData); | ||||
|     return App( | ||||
|         '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', | ||||
|         id ?? generateTempID(names, source), | ||||
|         standardUrl, | ||||
|         names.author[0].toUpperCase() + names.author.substring(1), | ||||
|         customName.trim().isNotEmpty | ||||
|             ? customName | ||||
|         name.trim().isNotEmpty | ||||
|             ? name | ||||
|             : names.name[0].toUpperCase() + names.name.substring(1), | ||||
|         null, | ||||
|         apk.version, | ||||
|         apk.version.replaceAll('/', '-'), | ||||
|         apk.apkUrls, | ||||
|         apk.apkUrls.length - 1, | ||||
|         additionalData, | ||||
|   | ||||
							
								
								
									
										142
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -14,7 +14,7 @@ packages: | ||||
|       name: archive | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.3.1" | ||||
|     version: "3.3.2" | ||||
|   args: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -112,42 +112,14 @@ packages: | ||||
|       name: device_info_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.0.5" | ||||
|   device_info_plus_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.2" | ||||
|   device_info_plus_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_macos | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.2" | ||||
|     version: "8.0.0" | ||||
|   device_info_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.1" | ||||
|   device_info_plus_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_web | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.2" | ||||
|   device_info_plus_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: device_info_plus_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.0.2" | ||||
|     version: "7.0.0" | ||||
|   dynamic_color: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -182,7 +154,7 @@ packages: | ||||
|       name: file_picker | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.2.0+1" | ||||
|     version: "5.2.2" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -215,14 +187,14 @@ packages: | ||||
|       name: flutter_local_notifications | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "12.0.0" | ||||
|     version: "12.0.3" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_local_notifications_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|     version: "2.0.0" | ||||
|   flutter_local_notifications_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -253,14 +225,14 @@ packages: | ||||
|       name: fluttertoast | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "8.0.9" | ||||
|     version: "8.1.1" | ||||
|   html: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: html | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.15.0" | ||||
|     version: "0.15.1" | ||||
|   http: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -274,14 +246,14 @@ packages: | ||||
|       name: http_parser | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.1" | ||||
|     version: "4.0.2" | ||||
|   image: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: image | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.2.0" | ||||
|     version: "3.2.2" | ||||
|   install_plugin_v2: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -289,6 +261,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   installed_apps: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: installed_apps | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.3.1" | ||||
|   js: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -309,7 +288,7 @@ packages: | ||||
|       name: lints | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|     version: "2.0.1" | ||||
|   matcher: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -323,7 +302,7 @@ packages: | ||||
|       name: material_color_utilities | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0" | ||||
|     version: "0.1.5" | ||||
|   meta: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -345,6 +324,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.0" | ||||
|   package_archive_info: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: package_archive_info | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|   package_info: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: package_info | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.2" | ||||
|   path: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -407,21 +400,21 @@ packages: | ||||
|       name: permission_handler | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.1.0" | ||||
|     version: "10.2.0" | ||||
|   permission_handler_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.1.0" | ||||
|     version: "10.2.0" | ||||
|   permission_handler_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "9.0.6" | ||||
|     version: "9.0.7" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -435,14 +428,14 @@ packages: | ||||
|       name: permission_handler_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.1" | ||||
|     version: "0.1.2" | ||||
|   petitparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: petitparser | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.0.0" | ||||
|     version: "5.1.0" | ||||
|   platform: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -470,49 +463,21 @@ packages: | ||||
|       name: provider | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.3" | ||||
|     version: "6.0.4" | ||||
|   share_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: share_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.5.3" | ||||
|   share_plus_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   share_plus_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_macos | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|     version: "6.1.0" | ||||
|   share_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.1.1" | ||||
|   share_plus_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_web | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.1.0" | ||||
|   share_plus_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|     version: "3.2.0" | ||||
|   shared_preferences: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -526,7 +491,7 @@ packages: | ||||
|       name: shared_preferences_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.13" | ||||
|     version: "2.0.14" | ||||
|   shared_preferences_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -580,7 +545,7 @@ packages: | ||||
|       name: source_span | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.1" | ||||
|     version: "1.9.0" | ||||
|   stack_trace: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -594,7 +559,7 @@ packages: | ||||
|       name: stream_channel | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.1" | ||||
|     version: "2.1.0" | ||||
|   string_scanner: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -615,7 +580,7 @@ packages: | ||||
|       name: test_api | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.4.14" | ||||
|     version: "0.4.12" | ||||
|   timezone: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -643,7 +608,7 @@ packages: | ||||
|       name: url_launcher_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.19" | ||||
|     version: "6.0.21" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -686,13 +651,20 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   uuid: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: uuid | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.6" | ||||
|   vector_math: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: vector_math | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.4" | ||||
|     version: "2.1.2" | ||||
|   webview_flutter: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -727,14 +699,14 @@ packages: | ||||
|       name: win32 | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|     version: "3.0.1" | ||||
|   workmanager: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: workmanager | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.5.0" | ||||
|     version: "0.5.1" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -757,5 +729,5 @@ packages: | ||||
|     source: hosted | ||||
|     version: "3.1.1" | ||||
| sdks: | ||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" | ||||
|   dart: ">=2.18.2 <3.0.0" | ||||
|   flutter: ">=3.3.0" | ||||
|   | ||||
							
								
								
									
										10
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						| @@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.5.6+27 # When changing this, update the tag in main() accordingly | ||||
| version: 0.6.5+49 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
|  | ||||
| # Dependencies specify other packages that your package needs in order to work. | ||||
| # To automatically upgrade your package dependencies to the latest versions | ||||
| @@ -49,11 +49,13 @@ dependencies: | ||||
|   url_launcher: ^6.1.5 | ||||
|   permission_handler: ^10.0.0 | ||||
|   fluttertoast: ^8.0.9 | ||||
|   device_info_plus: ^5.0.5 | ||||
|   device_info_plus: ^8.0.0 | ||||
|   file_picker: ^5.1.0 | ||||
|   animations: ^2.0.4 | ||||
|   install_plugin_v2: ^1.0.0 | ||||
|   share_plus: ^4.4.0 | ||||
|   share_plus: ^6.0.1 | ||||
|   installed_apps: ^1.3.1 | ||||
|   package_archive_info: ^0.1.0 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
|   | ||||