mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Rewrite update with more efficient polling and change detection
This commit is contained in:
+2
-2
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user