Rewrite update with more efficient polling and change detection

This commit is contained in:
franzap
2026-04-04 16:37:06 -03:00
parent d718fde99e
commit dc4b5cab07
7 changed files with 457 additions and 464 deletions
+2 -2
View File
@@ -273,9 +273,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
// Record app open time for background notification throttling
await secureStorage.setLastAppOpenedTime(DateTime.now());
// These run in background - don't block UI
// Ensure installed packages are available before anything categorizes
final packageManager = ref.read(packageManagerProvider.notifier);
unawaited(packageManager.syncInstalledPackages());
await packageManager.syncInstalledPackages();
final backgroundService = ref.read(backgroundUpdateServiceProvider);
unawaited(backgroundService.initialize());
+10
View File
@@ -14,6 +14,7 @@ import 'package:zapstore/screens/updates_screen.dart';
import 'package:zapstore/screens/profile_screen.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/deep_link_resolver.dart';
import 'package:zapstore/services/updates_service.dart';
/// Root paths for each navigation branch (used for back navigation handling)
const kBranchRoots = ['/search', '/updates', '/profile'];
@@ -200,6 +201,15 @@ final routerProvider = Provider<GoRouter>((ref) {
ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
);
// Re-derive catalog from local DB when arriving at updates tab so
// data written by other screens or the background service is visible
// without waiting for the next poll cycle.
if (isUpdatesRoute && !wasUpdatesRoute) {
unawaited(
ref.read(updatePollerProvider.notifier).refreshFromLocal(),
);
}
// Clear completed operations when navigating away from updates
if (wasUpdatesRoute && !isUpdatesRoute) {
ref.read(packageManagerProvider.notifier).clearCompletedOperations();
+11 -11
View File
@@ -10,7 +10,7 @@ import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/paged_subscription_notifier.dart';
import 'package:zapstore/widgets/app_stack_container.dart';
const int _kPageSize = 20;
const int _kPageSize = 60;
// ---------------------------------------------------------------------------
// Notifier & provider
@@ -446,12 +446,12 @@ class _MigrationBanner extends HookConsumerWidget {
onPressed: isLoading.value
? null
: () => _migrateStacks(
context,
ref,
isLoading,
progressCount,
stacks,
),
context,
ref,
isLoading,
progressCount,
stacks,
),
icon: isLoading.value
? const SizedBox(
width: 16,
@@ -505,13 +505,13 @@ class _MigrationBanner extends HookConsumerWidget {
}
// Add one second so relays accept the replacement
partialStack.event.createdAt = stack.event.createdAt.add(const Duration(seconds: 1));
partialStack.event.createdAt = stack.event.createdAt.add(
const Duration(seconds: 1),
);
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, relays: {'social', 'AppCatalog'});
ref.storage.publish({signedStack}, relays: {'social', 'AppCatalog'});
progressCount.value++;
}
-4
View File
@@ -119,12 +119,9 @@ class MobileScaffold extends ConsumerWidget {
),
),
);
// Watch categorized to keep poller alive (poller is watched by categorized)
final categorized = ref.watch(categorizedUpdatesProvider);
final poller = ref.watch(updatePollerProvider);
final updateCount = ref.watch(updateCountProvider);
// Spinner only during first-time sync; once a check has completed, show
// the count badge even while a subsequent check is in progress.
final isFirstSync =
poller.lastCheckTime == null &&
(categorized.showSkeleton || poller.isChecking);
@@ -321,7 +318,6 @@ class DesktopScaffold extends ConsumerWidget {
),
),
);
// Watch categorized to keep poller alive (poller is watched by categorized)
final categorized = ref.watch(categorizedUpdatesProvider);
final poller = ref.watch(updatePollerProvider);
final updateCount = ref.watch(updateCountProvider);
+14 -77
View File
@@ -17,6 +17,7 @@ import 'package:zapstore/router.dart';
import 'package:zapstore/services/package_manager/background_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/catalog_fetcher.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -139,18 +140,14 @@ Future<bool> _performWeeklyCleanup() async {
}
}
/// Background update check logic - runs in a separate isolate.
/// Note: Cannot directly reuse [CategorizedUpdatesNotifier] since this runs
/// in an isolated WorkManager context without the main app's Riverpod setup.
/// Background update check logic - runs in a separate isolate via WorkManager.
///
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
/// default relay if not provided.
Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
try {
// Use provided relays or fall back to default
final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'};
// Create a fresh provider container for background work
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
@@ -163,7 +160,6 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
);
try {
// Initialize Purplebase with same DB path as main app
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
@@ -176,69 +172,22 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
).future,
);
// Get installed packages directly from Android system
final packageManager = container.read(packageManagerProvider.notifier);
await packageManager.syncInstalledPackages();
final pmState = container.read(packageManagerProvider);
if (pmState.installed.isEmpty) {
return true; // No installed apps to check
return true;
}
final installedIds = pmState.installed.keys.toSet();
final platform = packageManager.platform;
// Installable-first: mirrors the foreground poller's fetch strategy.
// SoftwareAsset (3063) first, FileMetadata (1063) fallback, then Apps.
final storage = container.read(storageNotifierProvider.notifier);
final assets = await storage.query(
RequestFilter<SoftwareAsset>(
tags: {'#i': installedIds, '#f': {platform}},
).toRequest(subscriptionPrefix: 'app-bg-assets'),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
final catalog = await fetchCatalog(
storage: storage,
installedIds: pmState.installed.keys.toSet(),
platform: packageManager.platform,
subscriptionPrefix: 'app-bg',
);
final coveredIds = assets.map((a) => a.appIdentifier).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(subscriptionPrefix: 'app-bg-metadata'),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
if (metadatas.isNotEmpty) {
final releaseFilters = metadatas
.map((m) => m.release.req?.filters.firstOrNull)
.nonNulls
.toList();
if (releaseFilters.isNotEmpty) {
await storage.query(
Request<Release>(
releaseFilters,
subscriptionPrefix: 'app-bg-releases',
),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
}
}
final allCatalogedIds = {
...coveredIds,
...metadatas.map((m) => m.appIdentifier),
};
if (allCatalogedIds.isNotEmpty) {
await storage.query(
RequestFilter<App>(
tags: {'#d': allCatalogedIds, '#f': {platform}},
).toRequest(subscriptionPrefix: 'app-bg-apps'),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
// NIP-09: fetch kind-5 deletion events since last sync.
// No author filter — the AppCatalog relay is curated and the `since`
// cursor keeps incremental runs cheap. The DB layer auto-processes
@@ -251,8 +200,9 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
RequestFilter<EventDeletionRequest>(
since: lastDeletionSync,
limit: 99,
).toRequest(subscriptionPrefix: 'app-bg-deletions'),
).toRequest(),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-bg-deletions',
);
await secureStorage.setDeletionsSyncedUntil(DateTime.now());
if (deletionRequests.isNotEmpty) {
@@ -265,30 +215,19 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
}
}
// Determine which apps have updates directly from the installables we
// already fetched — avoids a relationship-loading re-query that would
// always produce null installables (no `and:` in background context).
final updatableInstallables = <String, Installable>{};
for (final asset in assets) {
if (packageManager.hasUpdate(asset.appIdentifier, asset)) {
updatableInstallables[asset.appIdentifier] = asset;
}
}
for (final meta in metadatas) {
if (!updatableInstallables.containsKey(meta.appIdentifier) &&
packageManager.hasUpdate(meta.appIdentifier, meta)) {
updatableInstallables[meta.appIdentifier] = meta;
for (final entry in catalog.installableByApp.entries) {
if (packageManager.hasUpdate(entry.key, entry.value)) {
updatableInstallables[entry.key] = entry.value;
}
}
// Show notification if updates found (throttled to once per 72h)
if (updatableInstallables.isNotEmpty) {
// Fetch App objects from local DB for display names only
final updatableApps = await storage.query(
RequestFilter<App>(
tags: {
'#d': updatableInstallables.keys.toSet(),
'#f': {platform},
'#f': {packageManager.platform},
},
).toRequest(),
source: const LocalSource(),
@@ -304,8 +243,6 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
container.dispose();
}
} catch (e) {
// Background task failed - return false to indicate failure
// WorkManager may retry based on configuration
return false;
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:models/models.dart';
/// Max identifiers per relay subscription to stay under the relay's 100-event
/// hard cap. Each app may have several versions on the relay, so a small batch
/// keeps every app represented in the response.
const kCatalogBatchSize = 60;
/// Raw catalog data returned by [fetchCatalog]. Callers decide what to do with
/// it (update UI state, compare for updates, show notifications, etc.).
class CatalogResult {
const CatalogResult({
required this.apps,
required this.installableByApp,
required this.catalogedIds,
});
final List<App> apps;
final Map<String, Installable> installableByApp;
final Set<String> catalogedIds;
static const empty = CatalogResult(
apps: [],
installableByApp: {},
catalogedIds: {},
);
}
/// Fetch the latest catalog data for a set of installed app identifiers.
///
/// Used by both the foreground update poller and the background WorkManager
/// task. Pass [source] to override the default [RemoteSource] (e.g. use
/// [LocalSource] for a cheap local-only re-derivation).
Future<CatalogResult> fetchCatalog({
required StorageNotifier storage,
required Set<String> installedIds,
required String platform,
required String subscriptionPrefix,
Source source = const RemoteSource(relays: 'AppCatalog', stream: false),
}) async {
if (installedIds.isEmpty) return CatalogResult.empty;
final installableByApp = <String, Installable>{};
final allApps = <String, App>{};
// ── Phase 1: Asset-first (3063 → 32267) ────────────────────────────
final assets = await batchedQuery<SoftwareAsset>(
storage: storage,
allIds: installedIds,
tagKey: '#i',
extraTags: {'#f': {platform}},
subscriptionPrefix: '$subscriptionPrefix-assets',
source: source,
);
for (final a in assets) {
final id = a.appIdentifier;
if (id.isEmpty) continue;
final existing = installableByApp[id];
if (existing == null ||
(a.versionCode ?? 0) > (existing.versionCode ?? 0)) {
installableByApp[id] = a;
}
}
final assetCoveredIds = installableByApp.keys.toSet();
if (assetCoveredIds.isNotEmpty) {
final apps = await storage.query(
RequestFilter<App>(
tags: {'#d': assetCoveredIds, '#f': {platform}},
).toRequest(),
source: source,
subscriptionPrefix: '$subscriptionPrefix-apps',
);
for (final app in apps) {
allApps[app.identifier] = app;
}
}
// ── Phase 2: Legacy (32267 → 30063 → 1063) ────────────────────────
// TODO(cleanup): Remove when all apps are migrated to 3063.
final uncoveredIds = installedIds.difference(assetCoveredIds);
if (uncoveredIds.isNotEmpty) {
final legacyApps = await storage.query(
RequestFilter<App>(
tags: {'#d': uncoveredIds, '#f': {platform}},
).toRequest(),
source: source,
subscriptionPrefix: '$subscriptionPrefix-legacy-apps',
);
if (legacyApps.isNotEmpty) {
final releaseFilters = legacyApps
.map((a) => a.latestRelease.req?.filters.firstOrNull)
.nonNulls
.toList();
var releases = const <Release>[];
if (releaseFilters.isNotEmpty) {
releases = await storage.query(
Request<Release>(releaseFilters),
source: source,
subscriptionPrefix: '$subscriptionPrefix-legacy-releases',
);
}
if (releases.isNotEmpty) {
final metadataFilters = releases
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
.nonNulls
.toList();
if (metadataFilters.isNotEmpty) {
final metadatas = await storage.query(
Request<FileMetadata>(metadataFilters),
source: source,
subscriptionPrefix: '$subscriptionPrefix-legacy-meta',
);
for (final m in metadatas) {
final id = m.appIdentifier;
if (id.isEmpty) continue;
final existing = installableByApp[id];
if (existing == null ||
(m.versionCode ?? 0) > (existing.versionCode ?? 0)) {
installableByApp[id] = m;
}
}
}
}
for (final app in legacyApps) {
allApps.putIfAbsent(app.identifier, () => app);
}
}
}
return CatalogResult(
apps: allApps.values.toList(),
installableByApp: installableByApp,
catalogedIds: installableByApp.keys.toSet(),
);
}
/// Query a model type in batches of [kCatalogBatchSize] identifiers to avoid
/// hitting the relay's per-subscription event cap.
Future<List<T>> batchedQuery<T extends Model<T>>({
required StorageNotifier storage,
required Set<String> allIds,
required String tagKey,
Map<String, Set<String>> extraTags = const {},
required String subscriptionPrefix,
Source source = const RemoteSource(relays: 'AppCatalog', stream: false),
}) async {
if (allIds.isEmpty) return const [];
final idList = allIds.toList();
final results = <T>[];
for (var i = 0; i < idList.length; i += kCatalogBatchSize) {
final batch = idList.sublist(
i,
(i + kCatalogBatchSize).clamp(0, idList.length),
);
final batchResults = await storage.query(
RequestFilter<T>(
tags: {tagKey: batch.toSet(), ...extraTags},
).toRequest(),
source: source,
subscriptionPrefix: '$subscriptionPrefix-$i',
);
results.addAll(batchResults);
}
return results;
}
+250 -370
View File
@@ -4,8 +4,8 @@ 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/catalog_fetcher.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);
@@ -14,233 +14,9 @@ const _pollInterval = Duration(minutes: 5);
const _refreshCooldown = Duration(seconds: 30);
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE POLLER - Handles remote fetching on timer + pull-to-refresh
// CATEGORIZED UPDATES
// ═══════════════════════════════════════════════════════════════════════════════
/// 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,
@@ -253,11 +29,7 @@ class CategorizedUpdates {
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(
@@ -269,158 +41,266 @@ class CategorizedUpdates {
);
}
class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
CategorizedUpdates? _lastCategorization;
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE POLLER STATE
// ═══════════════════════════════════════════════════════════════════════════════
@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));
class UpdatePollerState {
const UpdatePollerState({
this.isChecking = false,
this.lastCheckTime,
this.lastError,
this.apps = const [],
this.installableByApp = const {},
this.catalogedIds = const {},
});
final initState = ref.watch(appInitializationProvider);
if (initState is! AsyncData) {
return CategorizedUpdates.empty;
}
final bool isChecking;
final DateTime? lastCheckTime;
final String? lastError;
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();
/// App objects fetched from relay, used for display (name, icon, author)
final List<App> apps;
if (isScanning && installedIds.isEmpty) {
return CategorizedUpdates.empty;
}
/// appIdentifier → Installable (3063 or 1063), used for update comparison
final Map<String, Installable> installableByApp;
if (installedIds.isEmpty) {
_lastCategorization = null;
return const CategorizedUpdates(
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
showSkeleton: false,
);
}
/// All app identifiers found in relay catalog (superset of apps list,
/// since an installable may exist without a corresponding App object)
final Set<String> catalogedIds;
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,
UpdatePollerState copyWith({
bool? isChecking,
DateTime? lastCheckTime,
String? lastError,
bool clearError = false,
List<App>? apps,
Map<String, Installable>? installableByApp,
Set<String>? catalogedIds,
}) {
return UpdatePollerState(
isChecking: isChecking ?? this.isChecking,
lastCheckTime: lastCheckTime ?? this.lastCheckTime,
lastError: clearError ? null : (lastError ?? this.lastError),
apps: apps ?? this.apps,
installableByApp: installableByApp ?? this.installableByApp,
catalogedIds: catalogedIds ?? this.catalogedIds,
);
}
}
final categorizedUpdatesProvider =
NotifierProvider<CategorizedUpdatesNotifier, CategorizedUpdates>(
CategorizedUpdatesNotifier.new,
// ═══════════════════════════════════════════════════════════════════════════════
// UPDATE POLLER - Fetches catalog data from relays
// ═══════════════════════════════════════════════════════════════════════════════
class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
UpdatePollerNotifier(this.ref) : super(const UpdatePollerState()) {
_init();
}
final Ref ref;
Timer? _pollTimer;
void _init() {
ref.listen<AsyncValue<void>>(appInitializationProvider, (prev, next) {
if (prev is! AsyncData && next is AsyncData) {
_startPolling();
}
}, fireImmediately: true);
}
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(_pollInterval, (_) => checkNow());
checkNow();
}
/// Trigger an update check. Called by timer and pull-to-refresh.
Future<void> checkNow() async {
if (state.isChecking) return;
if (state.lastCheckTime != null) {
final elapsed = DateTime.now().difference(state.lastCheckTime!);
if (elapsed < _refreshCooldown) return;
}
state = state.copyWith(isChecking: true);
try {
await ref.read(packageManagerProvider.notifier).syncInstalledPackages();
await _fetchCatalog();
state = state.copyWith(
isChecking: false,
lastCheckTime: DateTime.now(),
clearError: true,
);
} catch (e) {
debugPrint('[UpdatePoller] Check failed: $e');
state = state.copyWith(
isChecking: false,
lastCheckTime: DateTime.now(),
lastError: 'Update check failed — will retry',
);
}
}
/// Fetch catalog data from relays and store in state.
/// Categorization is done by [categorizedUpdatesProvider].
Future<void> _fetchCatalog() async {
final pmState = ref.read(packageManagerProvider);
if (pmState.installed.isEmpty) {
state = state.copyWith(
apps: const [],
installableByApp: const {},
catalogedIds: const {},
);
return;
}
final storage = ref.read(storageNotifierProvider.notifier);
final result = await fetchCatalog(
storage: storage,
installedIds: pmState.installed.keys.toSet(),
platform: ref.read(packageManagerProvider.notifier).platform,
subscriptionPrefix: 'app-updates-poll',
);
/// Provider that calculates the total number of apps with available updates
final authorPubkeys = result.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',
),
);
}
state = state.copyWith(
apps: result.apps,
installableByApp: result.installableByApp,
catalogedIds: result.catalogedIds,
);
}
/// Re-derive catalog from local DB without hitting relays.
/// Call when returning to the updates screen so that data written by
/// other code paths (detail screen, background service) is picked up
/// without waiting for the next poll cycle.
Future<void> refreshFromLocal() async {
final pmState = ref.read(packageManagerProvider);
if (pmState.installed.isEmpty) return;
final storage = ref.read(storageNotifierProvider.notifier);
final result = await fetchCatalog(
storage: storage,
installedIds: pmState.installed.keys.toSet(),
platform: ref.read(packageManagerProvider.notifier).platform,
subscriptionPrefix: 'app-updates-local',
source: const LocalSource(),
);
state = state.copyWith(
apps: result.apps,
installableByApp: result.installableByApp,
catalogedIds: result.catalogedIds,
);
}
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
}
final updatePollerProvider =
StateNotifierProvider<UpdatePollerNotifier, UpdatePollerState>(
(ref) => UpdatePollerNotifier(ref),
);
// ═══════════════════════════════════════════════════════════════════════════════
// CATEGORIZED UPDATES PROVIDER
// ═══════════════════════════════════════════════════════════════════════════════
/// Pure synchronous derivation from poller catalog + installed packages.
/// Rebuilds when either changes.
final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
final pollerState = ref.watch(updatePollerProvider);
final installed = ref.watch(
packageManagerProvider.select((s) => s.installed),
);
if (pollerState.lastCheckTime == null) {
return CategorizedUpdates.empty;
}
if (installed.isEmpty) {
return const CategorizedUpdates(
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
);
}
final pm = ref.read(packageManagerProvider.notifier);
final installableByApp = pollerState.installableByApp;
final catalogedIds = pollerState.catalogedIds;
final automaticUpdates = <App>[];
final manualUpdates = <App>[];
final upToDateApps = <App>[];
for (final app in pollerState.apps) {
final pkg = installed[app.identifier];
if (pkg == null) continue;
final installable = installableByApp[app.identifier];
final hasUpdate =
installable != null && pm.hasUpdate(app.identifier, installable);
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 =
installed.values
.where((pkg) => !catalogedIds.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,
);
});
/// Total number of apps with available updates
final updateCountProvider = Provider<int>((ref) {
final categorized = ref.watch(categorizedUpdatesProvider);
if (categorized.showSkeleton) return 0;