mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-06 13:08:24 +00:00
429 lines
14 KiB
Dart
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;
|
|
});
|