From 5e70b44ebb29365bb6d91aacddea5dcd1c01b0d3 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 16 Dec 2025 18:34:02 -0300 Subject: [PATCH] Fix and simplify updates system --- .../plugins/AndroidPackageManagerPlugin.kt | 4 + lib/main.dart | 5 - lib/screens/main_scaffold.dart | 54 +++++- lib/screens/search_screen.dart | 5 +- lib/screens/updates_screen.dart | 24 ++- lib/services/background_update_service.dart | 71 ++++++-- lib/services/download/installation_queue.dart | 76 ++++---- .../android_package_manager.dart | 54 +++--- .../dummy_package_manager.dart | 6 - .../package_manager/package_manager.dart | 9 +- lib/services/updates_service.dart | 167 +++++++++--------- 11 files changed, 290 insertions(+), 185 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 71ab5ef..a946206 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -968,12 +968,16 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { "" } + // Check if we can silently update this package (we are the installer) + val canSilentlyUpdate = canInstallSilently(bundleId) + out.add(mapOf( "name" to name, "bundleId" to bundleId, "versionName" to versionName, "versionCode" to versionCode, "signatureHash" to signatureHash, + "canInstallSilently" to canSilentlyUpdate, )) } return out diff --git a/lib/main.dart b/lib/main.dart index e4c6e8b..f0b5b1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,6 @@ import 'package:zapstore/services/background_update_service.dart'; import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/package_manager/android_package_manager.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; @@ -79,10 +78,6 @@ class ZapstoreApp extends HookConsumerWidget { // Watch initialization state for error overlay display final initState = ref.watch(appInitializationProvider); - // Keep categorizedAppsProvider alive for badge count updates - // Using listen instead of watch since we don't use the value directly - ref.listen(categorizedAppsProvider, (_, __) {}); - // Listen to app lifecycle and check for updates when app regains focus useEffect(() { final observer = _AppLifecycleObserver(ref); diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index 3902a46..032429e 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -82,7 +82,9 @@ class MobileScaffold extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final pubkey = ref.watch(Signer.activePubkeyProvider); final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final categorized = ref.watch(categorizedAppsProvider); final updateCount = ref.watch(updateCountProvider); + final isLoadingUpdates = categorized.isLoading; return Scaffold( body: Container( @@ -169,7 +171,30 @@ class MobileScaffold extends ConsumerWidget { clipBehavior: Clip.none, children: [ const Icon(Icons.update), - if (updateCount > 0) + if (isLoadingUpdates) + Positioned( + right: -8, + top: -8, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ), + ) + else if (updateCount > 0) Positioned( right: -8, top: -8, @@ -255,7 +280,9 @@ class DesktopScaffold extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final pubkey = ref.watch(Signer.activePubkeyProvider); final profile = ref.watch(Signer.activeProfileProvider(LocalSource())); + final categorized = ref.watch(categorizedAppsProvider); final updateCount = ref.watch(updateCountProvider); + final isLoadingUpdates = categorized.isLoading; return Scaffold( body: Container( @@ -293,7 +320,30 @@ class DesktopScaffold extends ConsumerWidget { clipBehavior: Clip.none, children: [ const Icon(Icons.update), - if (updateCount > 0) + if (isLoadingUpdates) + Positioned( + right: -8, + top: -8, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: SizedBox( + width: 8, + height: 8, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ), + ) + else if (updateCount > 0) Positioned( right: -8, top: -8, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 77883a8..32f7c2e 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -43,10 +43,7 @@ class SearchScreen extends HookConsumerWidget { }, // Force the search to hit the default relay group (relay.zapstore.dev) // so a connection appears in Debug Info when searching. - source: const RemoteSource( - relays: 'AppCatalog', - stream: false, - ), + source: const RemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'search-results', ), ) diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 39828b5..a358486 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -86,6 +86,22 @@ class _UpdatesTab extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final categorized = ref.watch(categorizedAppsProvider); + + if (categorized.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return _UpdatesContent(categorized: categorized); + } +} + +class _UpdatesContent extends HookConsumerWidget { + const _UpdatesContent({required this.categorized}); + + final CategorizedApps categorized; + + @override + Widget build(BuildContext context, WidgetRef ref) { final automaticUpdates = categorized.automaticUpdates; final manualUpdates = categorized.manualUpdates; @@ -519,7 +535,13 @@ class _UpToDateTab extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final upToDateApps = ref.watch(categorizedAppsProvider).upToDateApps; + final categorized = ref.watch(categorizedAppsProvider); + + if (categorized.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final upToDateApps = categorized.upToDateApps; if (upToDateApps.isEmpty) { return Center( diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 31f32ab..0366c97 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:purplebase/purplebase.dart'; import 'package:workmanager/workmanager.dart'; import 'package:zapstore/services/package_manager/android_package_manager.dart'; @@ -87,7 +88,6 @@ Future _performWeeklyCleanup() async { // Remove from database await downloader.database.deleteRecordWithId(task.taskId); } catch (_) {} - } } @@ -173,10 +173,7 @@ Future _checkForUpdatesInBackground() async { '#f': {packageManager.platform}, }, ).toRequest(), - source: const RemoteSource( - relays: 'AppCatalog', - stream: false, - ), + source: const RemoteSource(relays: 'AppCatalog', stream: false), ); // Load releases for hasUpdate check @@ -326,8 +323,16 @@ class BackgroundUpdateService { ); } - /// Initialize local notifications plugin + /// Initialize local notifications plugin and request permission Future _initializeNotifications() async { + // Request notification permission on Android 13+ (API 33+) + if (Platform.isAndroid) { + final status = await Permission.notification.status; + if (!status.isGranted) { + await Permission.notification.request(); + } + } + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); const initializationSettingsAndroid = AndroidInitializationSettings( @@ -346,11 +351,10 @@ class BackgroundUpdateService { ); // Create notification channel on Android - final androidPlugin = - flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); + final androidPlugin = flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); if (androidPlugin != null) { await androidPlugin.createNotificationChannel( @@ -377,6 +381,51 @@ class BackgroundUpdateService { constraints: Constraints(networkType: NetworkType.connected), ); } + + /// Show a test notification directly (bypasses WorkManager, for debugging) + /// Returns true if notification was shown, false if permission denied + Future showTestNotification() async { + // Request notification permission if not granted + if (Platform.isAndroid) { + final status = await Permission.notification.status; + if (!status.isGranted) { + final result = await Permission.notification.request(); + if (!result.isGranted) { + return false; + } + } + } + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + const initializationSettingsAndroid = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + ); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + + const androidDetails = AndroidNotificationDetails( + kUpdateNotificationChannelId, + kUpdateNotificationChannelName, + channelDescription: kUpdateNotificationChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + showWhen: true, + autoCancel: true, + ); + + const notificationDetails = NotificationDetails(android: androidDetails); + + await flutterLocalNotificationsPlugin.show( + 99, // Test notification ID + 'Test notification', + 'This is a test notification from Zapstore', + notificationDetails, + ); + return true; + } } /// Provider for the background update service diff --git a/lib/services/download/installation_queue.dart b/lib/services/download/installation_queue.dart index 137112a..da3a3be 100644 --- a/lib/services/download/installation_queue.dart +++ b/lib/services/download/installation_queue.dart @@ -8,10 +8,8 @@ import '../secure_storage_service.dart'; import 'download_info.dart'; /// Callback to update state in the main service -typedef StateUpdater = void Function( - String appId, - DownloadInfo Function(DownloadInfo) updater, -); +typedef StateUpdater = + void Function(String appId, DownloadInfo Function(DownloadInfo) updater); /// Callback to remove from state typedef StateRemover = void Function(String appId); @@ -65,8 +63,10 @@ class InstallationQueue { final downloadInfo = _getState(appId); if (downloadInfo == null) return; - final packageManager = _ref.read(packageManagerProvider.notifier); - final canSilent = await packageManager.canInstallSilently(appId); + // Check silent install from PackageInfo (computed during syncInstalledPackages) + final packages = _ref.read(packageManagerProvider); + final packageInfo = packages.where((p) => p.appId == appId).firstOrNull; + final canSilent = packageInfo?.canInstallSilently ?? false; // If backgrounded and not silent install, defer if (!_isAppInForeground && !canSilent) { @@ -78,7 +78,8 @@ class InstallationQueue { // Check if permission dialog needs to be shown (Android, non-silent, first time) if (Platform.isAndroid && !canSilent) { final secureStorage = _ref.read(secureStorageServiceProvider); - final hasSeenDialog = await secureStorage.hasSeenInstallPermissionDialog(); + final hasSeenDialog = await secureStorage + .hasSeenInstallPermissionDialog(); if (!hasSeenDialog) { _updateState(appId, (info) => info.copyWith(isReadyToInstall: true)); return; @@ -122,24 +123,26 @@ class InstallationQueue { if (packageManager is AndroidPackageManager) { final retry = await packageManager.retryPendingInstall(appId); - if (retry.hasPending && (retry.relaunched || retry.promptAlreadyShown)) { + if (retry.hasPending && + (retry.relaunched || retry.promptAlreadyShown)) { continue; } if (retry.hasPending && retry.sessionPending) { - _updateState(appId, (info) => info.copyWith( - isInstalling: false, - isReadyToInstall: true, - )); + _updateState( + appId, + (info) => + info.copyWith(isInstalling: false, isReadyToInstall: true), + ); continue; } } // No pending prompt - mark ready to install - _updateState(appId, (info) => info.copyWith( - isInstalling: false, - isReadyToInstall: true, - )); + _updateState( + appId, + (info) => info.copyWith(isInstalling: false, isReadyToInstall: true), + ); } // Reset lock if there were stalled apps without prompts @@ -171,10 +174,11 @@ class InstallationQueue { try { final filePath = await downloadInfo.task.filePath(); - _updateState(appId, (info) => info.copyWith( - isInstalling: true, - isReadyToInstall: false, - )); + _updateState( + appId, + (info) => + info.copyWith(isInstalling: true, isReadyToInstall: false), + ); final packageManager = _ref.read(packageManagerProvider.notifier); await packageManager.install( @@ -207,14 +211,18 @@ class InstallationQueue { } } - void _handleInstallError(String appId, DownloadInfo downloadInfo, String errorMessage) { + void _handleInstallError( + String appId, + DownloadInfo downloadInfo, + String errorMessage, + ) { // Already in progress - put back and stop if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) { _queue.insert(0, appId); - _updateState(appId, (info) => info.copyWith( - isInstalling: true, - isReadyToInstall: false, - )); + _updateState( + appId, + (info) => info.copyWith(isInstalling: true, isReadyToInstall: false), + ); return; } @@ -228,14 +236,16 @@ class InstallationQueue { if (wasCancelled) { _removeFromState(appId); } else { - _updateState(appId, (info) => info.copyWith( - isInstalling: false, - isReadyToInstall: true, - errorDetails: isCertMismatch - ? 'CERTIFICATE_MISMATCH' - : errorMessage.replaceFirst('Exception: ', ''), - )); + _updateState( + appId, + (info) => info.copyWith( + isInstalling: false, + isReadyToInstall: true, + errorDetails: isCertMismatch + ? 'CERTIFICATE_MISMATCH' + : errorMessage.replaceFirst('Exception: ', ''), + ), + ); } } } - diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 508299f..eb4be63 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -34,13 +34,14 @@ final class AndroidPackageManager extends PackageManager { throw Exception('APK file not found: $filePath'); } - final result = await _channel.invokeMethod>('install', { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'skipVerification': skipVerification, - }); + final result = await _channel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'skipVerification': skipVerification, + }); final resultMap = Map.from(result ?? {}); @@ -62,9 +63,10 @@ final class AndroidPackageManager extends PackageManager { @override Future uninstall(String appId) async { - final result = await _channel.invokeMethod>('uninstall', { - 'packageName': appId, - }); + final result = await _channel.invokeMethod>( + 'uninstall', + {'packageName': appId}, + ); final resultMap = Map.from(result ?? {}); @@ -127,21 +129,6 @@ final class AndroidPackageManager extends PackageManager { return _supportsSilentInstall; } - /// Check if we can silently install/update a specific package - @override - Future canInstallSilently(String appId) async { - try { - final result = - await _channel.invokeMethod('canInstallSilently', { - 'packageName': appId, - }) ?? - false; - return result; - } catch (e) { - return false; - } - } - /// Install an update silently if possible, otherwise fall back to user confirmation Future installUpdate( String appId, @@ -184,15 +171,15 @@ final class AndroidPackageManager extends PackageManager { throw Exception('Failed to launch app: $e'); } } - + /// Re-launch a pending install prompt that was backgrounded. /// Returns structured info so callers can avoid double-prompts and bad state resets. Future retryPendingInstall(String appId) async { try { - final result = await _channel - .invokeMethod>('retryPendingInstall', { - 'packageName': appId, - }); + final result = await _channel.invokeMethod>( + 'retryPendingInstall', + {'packageName': appId}, + ); final resultMap = Map.from(result ?? {}); return RetryPendingInstallResult.fromMap(resultMap); @@ -222,11 +209,12 @@ final class AndroidPackageManager extends PackageManager { Future syncInstalledPackages() async { try { // Check and cache silent install capability (general check) - final canInstallSilently = + final canInstallSilentlyGeneral = await _channel.invokeMethod('canInstallSilently') ?? false; - _supportsSilentInstall = canInstallSilently; + _supportsSilentInstall = canInstallSilentlyGeneral; // Get installed apps via method channel, excluding system apps + // Each app includes per-package canInstallSilently status final installedApps = await _channel.invokeMethod>('getInstalledApps', { 'includeSystemApps': false, @@ -242,6 +230,7 @@ final class AndroidPackageManager extends PackageManager { final version = app['versionName'] as String? ?? '0.0.0'; final versionCode = app['versionCode'] as int?; final signatureHash = app['signatureHash'] as String? ?? ''; + final canInstallSilently = app['canInstallSilently'] as bool? ?? false; if (appId.isNotEmpty) { packages.add( @@ -251,6 +240,7 @@ final class AndroidPackageManager extends PackageManager { versionCode: versionCode, installTime: null, // Method channel doesn't provide install time signatureHash: signatureHash, + canInstallSilently: canInstallSilently, ), ); } diff --git a/lib/services/package_manager/dummy_package_manager.dart b/lib/services/package_manager/dummy_package_manager.dart index 164a390..b09ea53 100644 --- a/lib/services/package_manager/dummy_package_manager.dart +++ b/lib/services/package_manager/dummy_package_manager.dart @@ -87,12 +87,6 @@ final class DummyPackageManager extends PackageManager { @override bool get supportsSilentInstall => false; - @override - Future canInstallSilently(String appId) async { - // Mock implementation - always returns false for dummy manager - return false; - } - @override Future syncInstalledPackages() async { // No-op for dummy implementation diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index f91f1a2..1356309 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -14,6 +14,7 @@ class PackageInfo extends Equatable { required this.versionCode, required this.signatureHash, this.installTime, + this.canInstallSilently = false, }); /// The application identifier (bundle ID) @@ -31,6 +32,9 @@ class PackageInfo extends Equatable { /// When the app was installed final DateTime? installTime; + /// Whether this package can be silently updated (we are the installer) + final bool canInstallSilently; + @override List get props => [ appId, @@ -38,6 +42,7 @@ class PackageInfo extends Equatable { versionCode, signatureHash, installTime, + canInstallSilently, ]; } @@ -103,10 +108,6 @@ abstract class PackageManager extends StateNotifier> { /// Whether the platform supports silent installation bool get supportsSilentInstall; - /// Check if a specific package can be silently installed/updated - /// Returns false if not supported or if the package requires user confirmation - Future canInstallSilently(String appId); - /// Target platform identifier used to filter file metadata (e.g., '#f' tag) /// Example: 'android-arm64-v8a' for Android arm64 builds String get platform; diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 61fb69e..5b421e3 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/main.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -17,13 +21,6 @@ class CategorizedApps { final List upToDateApps; final bool isLoading; - static const empty = CategorizedApps( - automaticUpdates: [], - manualUpdates: [], - upToDateApps: [], - isLoading: true, - ); - CategorizedApps copyWith({ List? automaticUpdates, List? manualUpdates, @@ -37,37 +34,44 @@ class CategorizedApps { isLoading: isLoading ?? this.isLoading, ); } + + static const empty = CategorizedApps( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + isLoading: true, + ); } -/// Provider that maintains streaming subscription and categorizes apps -class CategorizedAppsNotifier extends StateNotifier { - CategorizedAppsNotifier(this._ref) : super(CategorizedApps.empty) { - // Listen to package manager changes and recreate query subscription - _ref.listen(packageManagerProvider, (_, packages) { - final installedIds = packages.map((p) => p.appId).toSet(); - final platform = _ref.read(packageManagerProvider.notifier).platform; - _recreateQuerySubscription(installedIds, platform); - }, fireImmediately: true); - } +class CategorizedAppsNotifier extends Notifier { + bool _hasLoadedOnce = false; - final Ref _ref; - ProviderSubscription>? _querySub; + @override + CategorizedApps build() { + // Wait for app initialization + final initState = ref.watch(appInitializationProvider); + if (initState is! AsyncData) { + return CategorizedApps.empty; + } - void _recreateQuerySubscription(Set installedIds, String platform) { - // Close old subscription - _querySub?.close(); + // Watch installed packages - this is the source of truth + final packages = ref.watch(packageManagerProvider); + final installedIds = packages.map((p) => p.appId).toSet(); if (installedIds.isEmpty) { - state = const CategorizedApps( + _hasLoadedOnce = true; + return const CategorizedApps( automaticUpdates: [], manualUpdates: [], upToDateApps: [], + isLoading: false, ); - return; } - // Create new streaming subscription - _querySub = _ref.listen( + final platform = ref.read(packageManagerProvider.notifier).platform; + + // Query apps with relationships loaded via `and:` + final appsState = ref.watch( query( tags: { '#d': installedIds, @@ -77,58 +81,39 @@ class CategorizedAppsNotifier extends StateNotifier { app.latestRelease, app.latestRelease.value?.latestMetadata, }, - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: true, - ), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), andSource: const LocalAndRemoteSource( relays: 'AppCatalog', stream: false, ), subscriptionPrefix: 'updates', ), - (_, appsState) { - // Fetch authors in background - _fetchAuthors(appsState.models); - - // Categorize - _categorizeApps(appsState.models); - }, - fireImmediately: true, ); + + return switch (appsState) { + StorageLoading() => CategorizedApps.empty.copyWith( + isLoading: !_hasLoadedOnce, + ), + StorageError() => CategorizedApps.empty.copyWith(isLoading: false), + StorageData(:final models) => _categorize(models, packages), + }; } - @override - void dispose() { - _querySub?.close(); - super.dispose(); - } + CategorizedApps _categorize(List apps, List packages) { + _hasLoadedOnce = true; - void _fetchAuthors(List apps) async { - final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); - if (authorPubkeys.isNotEmpty) { - await _ref.storage.query( - RequestFilter(authors: authorPubkeys).toRequest(), - source: const RemoteSource( - relays: 'social', - stream: false, - ), - ); - } - } - - void _categorizeApps(List apps) async { - final packageManager = _ref.read(packageManagerProvider.notifier); final automaticUpdates = []; final manualUpdates = []; final upToDateApps = []; - for (final app in apps) { + // Only process apps that are actually installed + final installedApps = apps.where((a) => a.installedPackage != null); + + for (final app in installedApps) { if (app.hasUpdate) { - final canSilentInstall = await packageManager.canInstallSilently( - app.identifier, - ); - if (canSilentInstall) { + // Look up silent install status from package info + final pkg = packages.firstWhereOrNull((p) => p.appId == app.identifier); + if (pkg?.canInstallSilently ?? false) { automaticUpdates.add(app); } else { manualUpdates.add(app); @@ -138,35 +123,43 @@ class CategorizedAppsNotifier extends StateNotifier { } } - // Sort alphabetically - automaticUpdates.sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - manualUpdates.sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - upToDateApps.sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); + int byName(App a, App b) => (a.name ?? a.identifier) + .toLowerCase() + .compareTo((b.name ?? b.identifier).toLowerCase()); - if (mounted) { - state = CategorizedApps( - automaticUpdates: automaticUpdates, - manualUpdates: manualUpdates, - upToDateApps: upToDateApps, - ); - } + automaticUpdates.sort(byName); + manualUpdates.sort(byName); + upToDateApps.sort(byName); + + // Fetch author profiles in background (fire and forget) + _fetchAuthors(installedApps); + + return CategorizedApps( + automaticUpdates: automaticUpdates, + manualUpdates: manualUpdates, + upToDateApps: upToDateApps, + isLoading: false, + ); + } + + void _fetchAuthors(Iterable apps) { + final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); + if (authorPubkeys.isEmpty) return; + unawaited( + ref.storage.query( + RequestFilter(authors: authorPubkeys).toRequest(), + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + stream: false, + ), + ), + ); } } final categorizedAppsProvider = - StateNotifierProvider( + NotifierProvider( CategorizedAppsNotifier.new, );