Files
zapstore/lib/services/updates_service.dart
T

429 lines
14 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
/// How often to poll for updates from remote relays
const _pollInterval = Duration(minutes: 5);
/// Minimum time between manual refreshes
const _refreshCooldown = Duration(seconds: 30);
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE POLLER - Handles remote fetching on timer + pull-to-refresh
// ═══════════════════════════════════════════════════════════════════════════════
/// State for the update poller
class UpdatePollerState {
const UpdatePollerState({
this.isChecking = false,
this.lastCheckTime,
this.lastError,
});
final bool isChecking;
final DateTime? lastCheckTime;
/// User-facing message from the last failed check, null when healthy.
final String? lastError;
UpdatePollerState copyWith({
bool? isChecking,
DateTime? lastCheckTime,
String? lastError,
bool clearError = false,
}) {
return UpdatePollerState(
isChecking: isChecking ?? this.isChecking,
lastCheckTime: lastCheckTime ?? this.lastCheckTime,
lastError: clearError ? null : (lastError ?? this.lastError),
);
}
}
/// Notifier that handles polling for updates from remote relays.
/// Kept alive by MainScaffold watching categorizedUpdatesProvider.
class UpdatePollerNotifier extends Notifier<UpdatePollerState> {
Timer? _pollTimer;
bool _hasCompletedFirstFetch = false;
/// Preserved state across rebuilds (build() would otherwise reset it)
UpdatePollerState? _preservedState;
@override
UpdatePollerState build() {
ref.onDispose(() {
_pollTimer?.cancel();
_preservedState = null;
_hasCompletedFirstFetch = false;
});
// Wait for app initialization before starting timer
final initState = ref.watch(appInitializationProvider);
if (initState is! AsyncData) {
return _preservedState ?? const UpdatePollerState();
}
// Listen (not watch) to package manager to trigger fetch when packages
// become available, without causing state reset on every change
ref.listen(packageManagerProvider, (prev, next) {
final hadPackages = prev?.installed.isNotEmpty ?? false;
final hasPackages = next.installed.isNotEmpty;
// Trigger initial fetch when packages become available for first time
if (!hadPackages && hasPackages && !_hasCompletedFirstFetch) {
checkNow();
}
});
// Start periodic polling timer (only once, timer persists across rebuilds)
if (_pollTimer == null) {
_pollTimer = Timer.periodic(_pollInterval, (_) => checkNow());
// Check if packages are already available
final pmState = ref.read(packageManagerProvider);
if (pmState.installed.isNotEmpty && !_hasCompletedFirstFetch) {
Future.microtask(() => checkNow());
}
}
// Return preserved state if we have one, otherwise initial state
return _preservedState ?? const UpdatePollerState();
}
/// Override state setter to preserve state across rebuilds
@override
set state(UpdatePollerState newState) {
_preservedState = newState;
super.state = newState;
}
/// Trigger an update check. Called by timer and pull-to-refresh.
///
/// - If already checking: returns immediately
/// - If checked <30s ago: silently skipped
Future<void> checkNow() async {
// Already checking - UI is already showing spinner, just return
if (state.isChecking) return;
// Throttle: skip if checked recently (but allow first fetch always)
if (_hasCompletedFirstFetch && state.lastCheckTime != null) {
final timeSinceLastCheck = DateTime.now().difference(
state.lastCheckTime!,
);
if (timeSinceLastCheck < _refreshCooldown) return;
}
state = state.copyWith(isChecking: true);
try {
// Sync installed packages first so relay data is compared against
// fresh installed state (catches sideloads, external updates, etc.)
await ref
.read(packageManagerProvider.notifier)
.syncInstalledPackages();
await _fetchUpdatesFromRemote();
_hasCompletedFirstFetch = true;
state = state.copyWith(
isChecking: false,
lastCheckTime: DateTime.now(),
clearError: true,
);
} catch (e) {
debugPrint('[UpdatePoller] Check failed: $e');
// Mark as completed even on failure so the UI escapes the skeleton and
// shows uncataloged apps rather than spinning forever.
_hasCompletedFirstFetch = true;
state = state.copyWith(
isChecking: false,
lastCheckTime: DateTime.now(),
lastError: 'Update check failed — will retry',
);
}
}
/// Fetch updates from remote relays and write to local DB.
///
/// Installable-first: fetches SoftwareAsset (3063) for installed apps,
/// then FileMetadata (1063) for any apps not covered by 3063, then
/// resolves parent Apps (32267) for display. All data lands in local DB
/// before the poller marks the check as complete.
Future<void> _fetchUpdatesFromRemote() async {
final pmState = ref.read(packageManagerProvider);
if (pmState.installed.isEmpty) return;
final installedIds = pmState.installed.keys.toSet();
final platform = ref.read(packageManagerProvider.notifier).platform;
final storage = ref.read(storageNotifierProvider.notifier);
// 1. Fetch SoftwareAsset (3063) — covers most apps
final assets = await storage.query(
RequestFilter<SoftwareAsset>(
tags: {'#i': installedIds, '#f': {platform}},
).toRequest(),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-updates-poll',
);
// 2. Fetch FileMetadata (1063) for apps not covered by 3063
final coveredIds =
assets.map((a) => a.appIdentifier).where((id) => id.isNotEmpty).toSet();
final uncoveredIds = installedIds.difference(coveredIds);
var metadatas = const <FileMetadata>[];
if (uncoveredIds.isNotEmpty) {
metadatas = await storage.query(
RequestFilter<FileMetadata>(
tags: {'#i': uncoveredIds, '#f': {platform}},
).toRequest(),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-updates-poll-legacy',
);
// Fetch Releases for 1063 apps (needed for App → Release → FileMetadata chain)
if (metadatas.isNotEmpty) {
final releaseFilters = metadatas
.map((m) => m.release.req?.filters.firstOrNull)
.nonNulls
.toList();
if (releaseFilters.isNotEmpty) {
await storage.query(
Request<Release>(releaseFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-updates-poll-legacy-releases',
);
}
}
}
// 3. Resolve parent Apps for display (name, icon, etc.)
final allCatalogedIds = {
...coveredIds,
...metadatas.map((m) => m.appIdentifier),
};
if (allCatalogedIds.isNotEmpty) {
final apps = await storage.query(
RequestFilter<App>(
tags: {'#d': allCatalogedIds, '#f': {platform}},
).toRequest(),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-updates-poll-apps',
);
// Author profiles for display (fire and forget)
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
if (authorPubkeys.isNotEmpty) {
unawaited(
storage.query(
RequestFilter<Profile>(authors: authorPubkeys).toRequest(),
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
stream: false,
),
subscriptionPrefix: 'app-updates-profiles',
),
);
}
}
}
}
final updatePollerProvider =
NotifierProvider<UpdatePollerNotifier, UpdatePollerState>(
UpdatePollerNotifier.new,
);
// ═══════════════════════════════════════════════════════════════════════════════
// CATEGORIZED UPDATES - Reactive query for UI
// ═══════════════════════════════════════════════════════════════════════════════
/// Categorized apps state for the Updates screen
class CategorizedUpdates {
const CategorizedUpdates({
required this.automaticUpdates,
required this.manualUpdates,
required this.upToDateApps,
required this.uncatalogedApps,
this.showSkeleton = false,
});
final List<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> upToDateApps;
/// Apps installed on device but not found in any relay catalog
final List<PackageInfo> uncatalogedApps;
/// Whether to show skeleton (cold start only)
final bool showSkeleton;
static const empty = CategorizedUpdates(
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
showSkeleton: true,
);
}
class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
CategorizedUpdates? _lastCategorization;
@override
CategorizedUpdates build() {
// Keep poller alive for periodic background refresh.
// Select a constant so poller state changes don't trigger rebuilds.
ref.watch(updatePollerProvider.select((_) => null));
final initState = ref.watch(appInitializationProvider);
if (initState is! AsyncData) {
return CategorizedUpdates.empty;
}
final installed = ref.watch(
packageManagerProvider.select((s) => s.installed),
);
final isScanning = ref.watch(
packageManagerProvider.select((s) => s.isScanning),
);
final installedPackages = installed.values.toList();
final installedIds = installed.keys.toSet();
if (isScanning && installedIds.isEmpty) {
return CategorizedUpdates.empty;
}
if (installedIds.isEmpty) {
_lastCategorization = null;
return const CategorizedUpdates(
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
showSkeleton: false,
);
}
final platform = ref.read(packageManagerProvider.notifier).platform;
// Reactive query: reads local cache immediately, fetches from relay on
// cold start, and refreshes whenever new data is written to local DB
// (e.g. by the periodic poller).
const source = LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
);
final appsState = ref.watch(
query<App>(
tags: {
'#d': installedIds,
'#f': {platform},
},
and: (app) => {
app.latestAsset.query(source: source),
app.latestRelease.query(
source: source,
and: (release) => {
release.latestMetadata.query(source: source),
},
),
},
source: source,
subscriptionPrefix: 'app-updates',
),
);
return switch (appsState) {
// Loading: use cached categorization if available, otherwise skeleton
StorageLoading(:final models) when models.isNotEmpty =>
_categorize(models, installedPackages, installedIds),
StorageLoading() =>
_lastCategorization ?? CategorizedUpdates.empty,
StorageError() =>
_lastCategorization ?? const CategorizedUpdates(
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
showSkeleton: false,
),
StorageData(:final models) => () {
final result = _categorize(models, installedPackages, installedIds);
_lastCategorization = result;
return result;
}(),
};
}
CategorizedUpdates _categorize(
List<App> apps,
List<PackageInfo> installedPackages,
Set<String> installedIds,
) {
final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg};
final catalogedAppIds = apps.map((a) => a.identifier).toSet();
final pm = ref.read(packageManagerProvider.notifier);
final automaticUpdates = <App>[];
final manualUpdates = <App>[];
final upToDateApps = <App>[];
for (final app in apps) {
final pkg = installedMap[app.identifier];
if (pkg == null) continue;
final latest = app.installable;
final hasUpdate = latest != null && pm.hasUpdate(app.identifier, latest);
if (hasUpdate) {
if (pkg.canInstallSilently) {
automaticUpdates.add(app);
} else {
manualUpdates.add(app);
}
} else {
upToDateApps.add(app);
}
}
int byName(App a, App b) => (a.name ?? a.identifier)
.toLowerCase()
.compareTo((b.name ?? b.identifier).toLowerCase());
automaticUpdates.sort(byName);
manualUpdates.sort(byName);
upToDateApps.sort(byName);
final uncatalogedApps = installedPackages
.where((pkg) => !catalogedAppIds.contains(pkg.appId))
.toList()
..sort(
(a, b) => (a.name ?? a.appId).toLowerCase().compareTo(
(b.name ?? b.appId).toLowerCase(),
),
);
return CategorizedUpdates(
automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates,
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
);
}
}
final categorizedUpdatesProvider =
NotifierProvider<CategorizedUpdatesNotifier, CategorizedUpdates>(
CategorizedUpdatesNotifier.new,
);
/// Provider that calculates the total number of apps with available updates
final updateCountProvider = Provider<int>((ref) {
final categorized = ref.watch(categorizedUpdatesProvider);
if (categorized.showSkeleton) return 0;
return categorized.automaticUpdates.length + categorized.manualUpdates.length;
});