Update checking improvements (#38)

Still no auto retry for rate-limit. Instead, rate-limit errors are ignored and the unchecked Apps have to wait until the next cycle. Even this needs more testing before release.
This commit is contained in:
Imran Remtulla
2022-09-27 23:20:39 -04:00
committed by GitHub
parent 77e1768f3b
commit dd193d62f2
10 changed files with 185 additions and 90 deletions

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitHub implements AppSource { class GitHub implements AppSource {
@@ -76,7 +77,10 @@ class GitHub implements AppSource {
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls']);
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
} }
throw couldNotFindReleases; throw couldNotFindReleases;

8
lib/custom_errors.dart Normal file
View File

@@ -0,0 +1,8 @@
class RateLimitError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes);
@override
String toString() =>
'Rate limit reached - try again in $remainingMinutes minutes';
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart'; import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
@@ -15,45 +16,73 @@ import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag = const String currentReleaseTag =
'v0.4.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES '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<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true);
List<String> 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<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
// List<String> 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') @pragma('vm:entry-point')
void bgTaskCallback() { void bgTaskCallback() {
// Background update checking process // Background process callback
Workmanager().executeTask((task, taskName) async { Workmanager().executeTask((task, inputData) async {
var notificationsProvider = NotificationsProvider(); return await bgUpdateCheck(inputData?['ignoreAfter']);
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
// List<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true);
List<App> newUpdates = await appsProvider.checkUpdates();
// List<String> 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);
}
}); });
} }
@@ -95,16 +124,6 @@ class MyApp extends StatelessWidget {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} else { } else {
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) { if (isFirstRun) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
@@ -119,9 +138,24 @@ class MyApp extends StatelessWidget {
currentReleaseTag, currentReleaseTag,
[], [],
0, 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( return DynamicColorBuilder(

View File

@@ -15,8 +15,8 @@ class GitHubStars implements MassAppSource {
if (args.length != requiredArgs.length) { if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided'; throw 'Wrong number of arguments provided';
} }
Response res = Response res = await get(Uri.parse(
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); '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) { if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>) return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String) .map((e) => e['html_url'] as String)

View File

@@ -85,6 +85,15 @@ class _AppPageState extends State<AppPage> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, 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( bottomSheet: Padding(

View File

@@ -40,7 +40,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
Future<List<List<String>>> addApps(List<String> urls) async { Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission(); await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls); List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0]; List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1]; Map<String, dynamic> errorsMap = results[1];
for (var app in apps) { for (var app in apps) {

View File

@@ -169,41 +169,41 @@ class _SettingsPageState extends State<SettingsPage> {
labelText: labelText:
'Background Update Checking Interval'), 'Background Update Checking Interval'),
value: settingsProvider.updateInterval, value: settingsProvider.updateInterval,
items: const [ items: updateIntervals.map((e) {
DropdownMenuItem( int displayNum = (e < 60
value: 15, ? e
child: Text('15 Minutes'), : e < 1440
), ? e / 60
DropdownMenuItem( : e / 1440)
value: 30, .round();
child: Text('30 Minutes'), var displayUnit = (e < 60
), ? 'Minute'
DropdownMenuItem( : e < 1440
value: 60, ? 'Hour'
child: Text('1 Hour'), : 'Day');
),
DropdownMenuItem( String display = e == 0
value: 360, ? 'Never - Manual Only'
child: Text('6 Hours'), : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
), return DropdownMenuItem(
DropdownMenuItem( value: e, child: Text(display));
value: 720, }).toList(),
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsProvider.updateInterval = value; settingsProvider.updateInterval = value;
} }
}), }),
const 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(), const Spacer(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -297,12 +297,23 @@ class AppsProvider with ChangeNotifier {
return null; return null;
} }
Future<List<App>> checkUpdates() async { Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async {
List<App> updates = []; List<App> updates = [];
if (!gettingUpdates) { if (!gettingUpdates) {
gettingUpdates = true; gettingUpdates = true;
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
if (ignoreAfter != null) {
appIds = appIds
.where((id) =>
apps[id]!.app.lastUpdateCheck != null &&
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
.toList();
}
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0)));
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? newApp = await getUpdate(appIds[i]); App? newApp = await getUpdate(appIds[i]);
if (newApp != null) { if (newApp != null) {

View File

@@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending } enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs; SharedPreferences? prefs;
@@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440; var min = prefs?.getInt('updateInterval') ?? 180;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {
if (min > i && i != 0) {
temp = i;
}
}
min = temp;
}
return min;
} }
set updateInterval(int min) { set updateInterval(int min) {

View File

@@ -38,6 +38,7 @@ class App {
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck;
App( App(
this.id, this.id,
this.url, this.url,
@@ -47,7 +48,8 @@ class App {
this.latestVersion, this.latestVersion,
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData); this.additionalData,
this.lastUpdateCheck);
@override @override
String toString() { String toString() {
@@ -69,7 +71,10 @@ class App {
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider().getSource(json['url']).additionalDataDefaults
: List<String>.from(jsonDecode(json['additionalData']))); : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@@ -80,7 +85,8 @@ class App {
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData) 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
}; };
} }
@@ -195,15 +201,17 @@ class SourceProvider {
apk.version, apk.version,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData); additionalData,
DateTime.now());
} }
/// Returns a length 2 list, where the first element is a list of Apps and /// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors /// the second is a Map<String, dynamic> of URLs and errors
Future<List<dynamic>> getApps(List<String> urls) async { Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(source, url, source.additionalDataDefaults));