diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index 6cbfc3f..aef1bf5 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class GitHub implements AppSource { @@ -76,7 +77,10 @@ class GitHub implements AppSource { return APKDetails(version, targetRelease['apkUrls']); } else { if (res.headers['x-ratelimit-remaining'] == '0') { - throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; + throw RateLimitError( + (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / + 60000000) + .round()); } throw couldNotFindReleases; diff --git a/lib/custom_errors.dart b/lib/custom_errors.dart new file mode 100644 index 0000000..732668b --- /dev/null +++ b/lib/custom_errors.dart @@ -0,0 +1,8 @@ +class RateLimitError { + late int remainingMinutes; + RateLimitError(this.remainingMinutes); + + @override + String toString() => + 'Rate limit reached - try again in $remainingMinutes minutes'; +} diff --git a/lib/main.dart b/lib/main.dart index c1cfefd..87e25cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/app_sources/github.dart'; +import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/home.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart'; @@ -15,45 +16,73 @@ import 'package:device_info_plus/device_info_plus.dart'; const String currentReleaseTag = 'v0.4.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES +const String bgUpdateCheckTaskName = 'bg-update-check'; + +bgUpdateCheck(int? ignoreAfterMicroseconds) async { + DateTime? ignoreAfter = ignoreAfterMicroseconds != null + ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) + : null; + var notificationsProvider = NotificationsProvider(); + await notificationsProvider.notify(checkingUpdatesNotification); + try { + var appsProvider = AppsProvider(); + await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id); + await appsProvider.loadApps(); + // List existingUpdateIds = // TODO: Uncomment this and below when it works + // appsProvider.getExistingUpdates(installedOnly: true); + List existingUpdateIds = + appsProvider.getExistingUpdates(installedOnly: true); + // DateTime nextIgnoreAfter = DateTime.now(); + try { + await appsProvider.checkUpdates(ignoreAfter: ignoreAfter); + } catch (e) { + if (e is RateLimitError) { + // Ignore these (scheduling another task as below does not work) + // Workmanager().registerOneOffTask( + // bgUpdateCheckTaskName, bgUpdateCheckTaskName, + // constraints: Constraints(networkType: NetworkType.connected), + // initialDelay: Duration(minutes: e.remainingMinutes), + // inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch}); + } else { + rethrow; + } + } + List newUpdates = appsProvider + .getExistingUpdates(installedOnly: true) + .where((id) => !existingUpdateIds.contains(id)) + .map((e) => appsProvider.apps[e]!.app) + .toList(); + // List silentlyUpdated = await appsProvider + // .downloadAndInstallLatestApp( + // [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); + // if (silentlyUpdated.isNotEmpty) { + // newUpdates + // .where((element) => !silentlyUpdated.contains(element.id)) + // .toList(); + // notificationsProvider.notify( + // SilentUpdateNotification( + // silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), + // cancelExisting: true); + // } + if (newUpdates.isNotEmpty) { + notificationsProvider.notify(UpdateNotification(newUpdates), + cancelExisting: true); + } + return Future.value(true); + } catch (e) { + notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()), + cancelExisting: true); + return Future.error(false); + } finally { + await notificationsProvider.cancel(checkingUpdatesNotification.id); + } +} + @pragma('vm:entry-point') void bgTaskCallback() { - // Background update checking process - Workmanager().executeTask((task, taskName) async { - var notificationsProvider = NotificationsProvider(); - await notificationsProvider.notify(checkingUpdatesNotification); - try { - var appsProvider = AppsProvider(); - await notificationsProvider - .cancel(ErrorCheckingUpdatesNotification('').id); - await appsProvider.loadApps(); - // List existingUpdateIds = // TODO: Uncomment this and below when it works - // appsProvider.getExistingUpdates(installedOnly: true); - List newUpdates = await appsProvider.checkUpdates(); - // List silentlyUpdated = await appsProvider - // .downloadAndInstallLatestApp( - // [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); - // if (silentlyUpdated.isNotEmpty) { - // newUpdates - // .where((element) => !silentlyUpdated.contains(element.id)) - // .toList(); - // notificationsProvider.notify( - // SilentUpdateNotification( - // silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()), - // cancelExisting: true); - // } - if (newUpdates.isNotEmpty) { - notificationsProvider.notify(UpdateNotification(newUpdates), - cancelExisting: true); - } - return Future.value(true); - } catch (e) { - notificationsProvider.notify( - ErrorCheckingUpdatesNotification(e.toString()), - cancelExisting: true); - return Future.value(false); - } finally { - await notificationsProvider.cancel(checkingUpdatesNotification.id); - } + // Background process callback + Workmanager().executeTask((task, inputData) async { + return await bgUpdateCheck(inputData?['ignoreAfter']); }); } @@ -95,16 +124,6 @@ class MyApp extends StatelessWidget { if (settingsProvider.prefs == null) { settingsProvider.initializeSettings(); } else { - // Register the background update task according to the user's setting - if (settingsProvider.updateInterval > 0) { - Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', - frequency: Duration(minutes: settingsProvider.updateInterval), - initialDelay: Duration(minutes: settingsProvider.updateInterval), - constraints: Constraints(networkType: NetworkType.connected), - existingWorkPolicy: ExistingWorkPolicy.replace); - } else { - Workmanager().cancelByUniqueName('bg-update-check'); - } bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); if (isFirstRun) { // If this is the first run, ask for notification permissions and add Obtainium to the Apps list @@ -119,9 +138,24 @@ class MyApp extends StatelessWidget { currentReleaseTag, [], 0, - ['true']) + ['true'], + null) ]); } + // Register the background update task according to the user's setting + if (settingsProvider.updateInterval == 0) { + Workmanager().cancelByUniqueName(bgUpdateCheckTaskName); + } else { + Workmanager().registerPeriodicTask( + bgUpdateCheckTaskName, bgUpdateCheckTaskName, + frequency: Duration(minutes: settingsProvider.updateInterval), + initialDelay: Duration(minutes: settingsProvider.updateInterval), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.keep, + backoffPolicy: BackoffPolicy.linear, + backoffPolicyDelay: + const Duration(minutes: minUpdateIntervalMinutes)); + } } return DynamicColorBuilder( diff --git a/lib/mass_app_sources/githubstars.dart b/lib/mass_app_sources/githubstars.dart index 0575d2f..d344f58 100644 --- a/lib/mass_app_sources/githubstars.dart +++ b/lib/mass_app_sources/githubstars.dart @@ -15,8 +15,8 @@ class GitHubStars implements MassAppSource { if (args.length != requiredArgs.length) { throw 'Wrong number of arguments provided'; } - Response res = - await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); + Response res = await get(Uri.parse( + 'https://api.github.com/users/${args[0]}/starred?per_page=100')); //TODO: Make requests for more pages until you run out if (res.statusCode == 200) { return (jsonDecode(res.body) as List) .map((e) => e['html_url'] as String) diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 2bcc573..c4de228 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -85,6 +85,15 @@ class _AppPageState extends State { textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), + const SizedBox( + height: 32, + ), + Text( + 'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}', + textAlign: TextAlign.center, + style: const TextStyle( + fontStyle: FontStyle.italic, fontSize: 12), + ) ], ), bottomSheet: Padding( diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index 994820a..a3097c7 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -40,7 +40,8 @@ class _ImportExportPageState extends State { Future>> addApps(List urls) async { await settingsProvider.getInstallPermission(); - List results = await sourceProvider.getApps(urls); + List results = await sourceProvider.getApps(urls, + ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList()); List apps = results[0]; Map errorsMap = results[1]; for (var app in apps) { diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 4dceea2..bdaeefc 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -169,41 +169,41 @@ class _SettingsPageState extends State { labelText: 'Background Update Checking Interval'), value: settingsProvider.updateInterval, - items: const [ - DropdownMenuItem( - value: 15, - child: Text('15 Minutes'), - ), - DropdownMenuItem( - value: 30, - child: Text('30 Minutes'), - ), - DropdownMenuItem( - value: 60, - child: Text('1 Hour'), - ), - DropdownMenuItem( - value: 360, - child: Text('6 Hours'), - ), - DropdownMenuItem( - value: 720, - child: Text('12 Hours'), - ), - DropdownMenuItem( - value: 1440, - child: Text('1 Day'), - ), - DropdownMenuItem( - value: 0, - child: Text('Never - Manual Only'), - ), - ], + items: updateIntervals.map((e) { + int displayNum = (e < 60 + ? e + : e < 1440 + ? e / 60 + : e / 1440) + .round(); + var displayUnit = (e < 60 + ? 'Minute' + : e < 1440 + ? 'Hour' + : 'Day'); + + String display = e == 0 + ? 'Never - Manual Only' + : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; + return DropdownMenuItem( + value: e, child: Text(display)); + }).toList(), onChanged: (value) { if (value != null) { settingsProvider.updateInterval = value; } }), + const SizedBox( + height: 8, + ), + Text( + 'Large App collections may require multiple cycles', + style: Theme.of(context) + .textTheme + .labelMedium! + .merge(const TextStyle( + fontStyle: FontStyle.italic)), + ), const Spacer(), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 252d8a4..3809f7d 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -297,12 +297,23 @@ class AppsProvider with ChangeNotifier { return null; } - Future> checkUpdates() async { + Future> checkUpdates({DateTime? ignoreAfter}) async { List updates = []; if (!gettingUpdates) { gettingUpdates = true; List 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) { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index c9538a0..33b6e3b 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName } enum SortOrderSettings { ascending, descending } +const maxAPIRateLimitMinutes = 30; +const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30; +const maxUpdateIntervalMinutes = 4320; +List updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0] + .where((element) => + (element >= minUpdateIntervalMinutes && + element <= maxUpdateIntervalMinutes) || + element == 0) + .toList(); + class SettingsProvider with ChangeNotifier { SharedPreferences? prefs; @@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier { } int get updateInterval { - return prefs?.getInt('updateInterval') ?? 1440; + var min = prefs?.getInt('updateInterval') ?? 180; + if (!updateIntervals.contains(min)) { + var temp = updateIntervals[0]; + for (var i in updateIntervals) { + if (min > i && i != 0) { + temp = i; + } + } + min = temp; + } + return min; } set updateInterval(int min) { diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 5da451f..6cb34fc 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -38,6 +38,7 @@ class App { List apkUrls = []; late int preferredApkIndex; late List additionalData; + late DateTime? lastUpdateCheck; App( this.id, this.url, @@ -47,7 +48,8 @@ class App { this.latestVersion, this.apkUrls, this.preferredApkIndex, - this.additionalData); + this.additionalData, + this.lastUpdateCheck); @override String toString() { @@ -69,7 +71,10 @@ class App { json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['additionalData'] == null ? SourceProvider().getSource(json['url']).additionalDataDefaults - : List.from(jsonDecode(json['additionalData']))); + : List.from(jsonDecode(json['additionalData'])), + json['lastUpdateCheck'] == null + ? null + : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck'])); Map toJson() => { 'id': id, @@ -80,7 +85,8 @@ class App { 'latestVersion': latestVersion, 'apkUrls': jsonEncode(apkUrls), 'preferredApkIndex': preferredApkIndex, - 'additionalData': jsonEncode(additionalData) + 'additionalData': jsonEncode(additionalData), + 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch }; } @@ -195,15 +201,17 @@ class SourceProvider { apk.version, apk.apkUrls, apk.apkUrls.length - 1, - additionalData); + additionalData, + DateTime.now()); } /// Returns a length 2 list, where the first element is a list of Apps and /// the second is a Map of URLs and errors - Future> getApps(List urls) async { + Future> getApps(List urls, + {List ignoreUrls = const []}) async { List apps = []; Map errors = {}; - for (var url in urls) { + for (var url in urls.where((element) => !ignoreUrls.contains(element))) { try { var source = getSource(url); apps.add(await getApp(source, url, source.additionalDataDefaults));