Files
Obtainium/lib/providers/apps_provider_updates.dart
2026-07-15 11:22:06 +01:00

275 lines
9.5 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
/// Update checking and pending-update bookkeeping for [AppsProvider].
extension AppsProviderUpdates on AppsProvider {
/// Fetches the latest [App] metadata from its source WITHOUT persisting it.
/// Returns null if the app is missing or has a pending repo rename.
///
/// Keeping fetch and save separate lets [checkUpdates] batch many checks into
/// a few [saveApps] calls instead of saving (and triggering a full UI
/// rebuild) once per app.
Future<App?> fetchUpdate(String appId) async {
final App? currentApp = apps[appId]?.app;
// Pause update checks until the user resolves a pending repo rename.
if (currentApp == null || currentApp.hasPendingRepoRename) {
return null;
}
final SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp(
sourceProvider.getSource(
currentApp.url,
overrideSource: currentApp.overrideSource,
),
currentApp.url,
currentApp.additionalSettings,
currentApp: currentApp,
);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp = newApp.copyWith(preferredApkIndex: currentApp.preferredApkIndex);
} else if (newApp.apkUrls.isNotEmpty) {
newApp = newApp.copyWith(preferredApkIndex: 0);
}
return newApp;
}
Future<App?> checkUpdate(String appId) async {
final App? currentApp = apps[appId]?.app;
if (currentApp == null) return null;
final App? newApp = await fetchUpdate(appId);
if (newApp == null) {
return null;
}
await saveApps([newApp]);
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
}
/// Returns app IDs sorted by last update check time, oldest first.
/// When [forceAll] is false, only includes apps whose per-app lastUpdateCheck
/// is older than the configured update interval (or null — never checked).
/// When [forceAll] is true, includes all apps regardless of interval.
List<String> getAppsSortedByUpdateCheckTime({
bool onlyCheckInstalledOrTrackOnlyApps = false,
bool forceAll = false,
}) {
final minAge = DateTime.now().subtract(
Duration(minutes: settingsProvider.updateInterval),
);
final List<String> appIds = apps.values
.where(
(app) =>
forceAll ||
app.app.lastUpdateCheck == null ||
app.app.lastUpdateCheck!.isBefore(minAge),
)
.where((app) {
if (!onlyCheckInstalledOrTrackOnlyApps) {
return true;
} else {
return app.app.installedVersion != null ||
app.app.settings.getBool('trackOnly');
}
})
.map((e) => e.app.id)
.toList();
appIds.sort(
(a, b) =>
(apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(
apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0),
),
);
return appIds;
}
Future<List<App>> checkUpdates({
bool throwErrorsForRetry = false,
List<String>? specificIds,
bool forceAll = false,
SettingsProvider? sp,
}) async {
final SettingsProvider settingsProvider = sp ?? this.settingsProvider;
if (updateCheckCompleter != null) {
return updateCheckCompleter!.future;
}
final completer = updateCheckCompleter = Completer<List<App>>();
var completed = 0;
var total = 0;
refreshProgress.value = 0.0;
final progressTimer = Timer.periodic(const Duration(milliseconds: 250), (
_,
) {
refreshProgress.value = total > 0 ? completed / total : 0.0;
});
try {
final List<App> updates = [];
final MultiAppMultiError errors = MultiAppMultiError();
List<String> appIds;
if (specificIds != null) {
appIds = List.from(specificIds);
} else if (forceAll) {
appIds = apps.values.map((e) => e.app.id).toList();
appIds.sort(
(a, b) =>
(apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(
apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0),
),
);
if (settingsProvider.onlyCheckInstalledOrTrackOnlyApps) {
appIds.removeWhere((id) {
final a = apps[id]?.app;
return a?.installedVersion == null &&
a?.settings.getBool('trackOnly') != true;
});
}
} else {
appIds = getAppsSortedByUpdateCheckTime(
onlyCheckInstalledOrTrackOnlyApps:
settingsProvider.onlyCheckInstalledOrTrackOnlyApps,
);
}
total = appIds.length;
final List<App> fetched = [];
var lastSaveTime = DateTime.now();
const saveInterval = Duration(seconds: 3);
// Fetching an app runs synchronous, CPU-bound work (HTML/JSON parsing)
// on the UI isolate. Firing every check at once saturates the event loop
// and freezes the UI for the whole refresh. Bound the number of in-flight
// checks so the isolate has room to render frames between them.
const maxConcurrent = 4;
var nextIndex = 0;
Future<MapEntry<App, bool>?> fetchOne(String appId) async {
final currentApp = apps[appId]?.app;
try {
final newApp = await fetchUpdate(appId);
if (newApp != null) {
final isUpdate =
currentApp != null &&
newApp.latestVersion != currentApp.latestVersion;
return MapEntry(newApp, isUpdate);
}
} on HandshakeException {
// Concurrent TLS handshakes to the same host can fail on
// certain devices/networks. Retry up to 5 times with
// staggered random delays to avoid all retries colliding.
const maxRetries = 5;
final rng = Random();
for (var attempt = 0; attempt < maxRetries; attempt++) {
await Future.delayed(
Duration(milliseconds: 250 + rng.nextInt(501)),
);
try {
final newApp = await fetchUpdate(appId);
if (newApp != null) {
final isUpdate =
currentApp != null &&
newApp.latestVersion != currentApp.latestVersion;
return MapEntry(newApp, isUpdate);
}
break;
} on HandshakeException {
if (attempt == maxRetries - 1) rethrow;
}
}
} catch (e) {
if ((e is RateLimitError || e is SocketException) &&
throwErrorsForRetry) {
rethrow;
}
if (e is RepositoryRenamedError) {
await updatePendingRepoRename(appId, e.newUrl);
} else {
errors.add(appId, e, appName: apps[appId]?.name);
}
}
return null;
}
Future<void> worker() async {
while (true) {
final int i = nextIndex++;
if (i >= appIds.length) return;
final result = await fetchOne(appIds[i]);
completed++;
// Save on a fixed time interval rather than per-app or per-N apps,
// so the UI rebuilds a bounded number of times regardless of how
// many apps are checked or how fast they complete.
if (result != null) {
fetched.add(result.key);
if (result.value) updates.add(result.key);
}
if (fetched.isNotEmpty &&
DateTime.now().difference(lastSaveTime) >= saveInterval) {
final batch = List<App>.from(fetched);
fetched.clear();
lastSaveTime = DateTime.now();
await saveApps(batch, reuseInstalledInfo: true);
}
}
}
await Future.wait(
List.generate(min(maxConcurrent, appIds.length), (_) => worker()),
eagerError: true,
);
if (fetched.isNotEmpty) {
await saveApps(fetched, reuseInstalledInfo: true);
}
if (errors.idsByErrorString.isNotEmpty) {
final ex = CheckUpdatesException(updates, errors);
completer.completeError(ex);
throw ex;
}
completer.complete(updates);
return updates;
} catch (e) {
if (!completer.isCompleted) {
completer.completeError(e);
}
rethrow;
} finally {
progressTimer.cancel();
updateCheckCompleter = null;
refreshProgress.value = null;
notify();
}
}
/// Finds app IDs whose installed version differs from the latest version, with optional filtering.
List<String> findAppIdsWithPendingUpdates({
bool installedOnly = false,
bool nonInstalledOnly = false,
}) {
final List<String> updateAppIds = [];
for (final appId in apps.keys) {
final app = apps[appId]!.app;
if (installedOnly) {
if (app.installedVersion != null &&
app.installedVersion != app.latestVersion) {
updateAppIds.add(app.id);
}
} else if (nonInstalledOnly) {
if (app.installedVersion == null) {
updateAppIds.add(app.id);
}
} else if (app.installedVersion != app.latestVersion) {
updateAppIds.add(app.id);
}
}
return updateAppIds;
}
}