diff --git a/lib/main.dart b/lib/main.dart index 546f286..49e8ae0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -273,9 +273,9 @@ final appInitializationProvider = FutureProvider((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()); diff --git a/lib/router.dart b/lib/router.dart index 717059c..8c01ffa 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -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((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(); diff --git a/lib/screens/app_stacks_screen.dart b/lib/screens/app_stacks_screen.dart index 8d19da1..2b04f58 100644 --- a/lib/screens/app_stacks_screen.dart +++ b/lib/screens/app_stacks_screen.dart @@ -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++; } diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index b9de980..29d8885 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -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); diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 4874bd1..fd8f99a 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -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 _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 _checkForUpdatesInBackground(Set? 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 _checkForUpdatesInBackground(Set? 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 _checkForUpdatesInBackground(Set? 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( - 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 []; - if (uncoveredIds.isNotEmpty) { - metadatas = await storage.query( - RequestFilter( - 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( - 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( - 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 _checkForUpdatesInBackground(Set? appCatalogRelays) async { RequestFilter( 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 _checkForUpdatesInBackground(Set? 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 = {}; - 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( tags: { '#d': updatableInstallables.keys.toSet(), - '#f': {platform}, + '#f': {packageManager.platform}, }, ).toRequest(), source: const LocalSource(), @@ -304,8 +243,6 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { container.dispose(); } } catch (e) { - // Background task failed - return false to indicate failure - // WorkManager may retry based on configuration return false; } } diff --git a/lib/services/catalog_fetcher.dart b/lib/services/catalog_fetcher.dart new file mode 100644 index 0000000..b9cba15 --- /dev/null +++ b/lib/services/catalog_fetcher.dart @@ -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 apps; + final Map installableByApp; + final Set 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 fetchCatalog({ + required StorageNotifier storage, + required Set installedIds, + required String platform, + required String subscriptionPrefix, + Source source = const RemoteSource(relays: 'AppCatalog', stream: false), +}) async { + if (installedIds.isEmpty) return CatalogResult.empty; + + final installableByApp = {}; + final allApps = {}; + + // ── Phase 1: Asset-first (3063 → 32267) ──────────────────────────── + final assets = await batchedQuery( + 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( + 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( + 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 []; + if (releaseFilters.isNotEmpty) { + releases = await storage.query( + Request(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(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> batchedQuery>({ + required StorageNotifier storage, + required Set allIds, + required String tagKey, + Map> 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 = []; + 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( + tags: {tagKey: batch.toSet(), ...extraTags}, + ).toRequest(), + source: source, + subscriptionPrefix: '$subscriptionPrefix-$i', + ); + results.addAll(batchResults); + } + return results; +} diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index e155771..31a4ef7 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -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 { - 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 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 _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( - 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 []; - if (uncoveredIds.isNotEmpty) { - metadatas = await storage.query( - RequestFilter( - 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(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( - 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(authors: authorPubkeys).toRequest(), - source: const LocalAndRemoteSource( - relays: {'social', 'vertex'}, - cachedFor: Duration(hours: 2), - stream: false, - ), - subscriptionPrefix: 'app-updates-profiles', - ), - ); - } - } - } -} - -final updatePollerProvider = - NotifierProvider( - 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 automaticUpdates; final List manualUpdates; final List upToDateApps; - - /// Apps installed on device but not found in any relay catalog final List 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? _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 apps; - if (isScanning && installedIds.isEmpty) { - return CategorizedUpdates.empty; - } + /// appIdentifier → Installable (3063 or 1063), used for update comparison + final Map 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 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( - 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 apps, - List installedPackages, - Set 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 = []; - final manualUpdates = []; - final upToDateApps = []; - - 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? apps, + Map? installableByApp, + Set? 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.new, +// ═══════════════════════════════════════════════════════════════════════════════ +// UPDATE POLLER - Fetches catalog data from relays +// ═══════════════════════════════════════════════════════════════════════════════ + +class UpdatePollerNotifier extends StateNotifier { + UpdatePollerNotifier(this.ref) : super(const UpdatePollerState()) { + _init(); + } + + final Ref ref; + Timer? _pollTimer; + + void _init() { + ref.listen>(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 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 _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(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 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( + (ref) => UpdatePollerNotifier(ref), + ); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CATEGORIZED UPDATES PROVIDER +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Pure synchronous derivation from poller catalog + installed packages. +/// Rebuilds when either changes. +final categorizedUpdatesProvider = Provider((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 = []; + final manualUpdates = []; + final upToDateApps = []; + + 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((ref) { final categorized = ref.watch(categorizedUpdatesProvider); if (categorized.showSkeleton) return 0;