diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5c37acf..b1e2ba0 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,9 +9,11 @@
+
+
((ref) async {
final packageManager = ref.read(packageManagerProvider.notifier);
await packageManager.syncInstalledPackages();
+ // Initialize background update service (WorkManager + notifications)
+ final backgroundService = ref.read(backgroundUpdateServiceProvider);
+ await backgroundService.initialize();
+
// Initialize market intent handling (for market:// URIs)
await ref.read(marketIntentServiceProvider).initialize();
@@ -265,12 +270,14 @@ Future onSignInSuccess(dynamic ref) async {
final storage =
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
- await storage.query(
- RequestFilter(authors: {pubkey}).toRequest(),
- source: const RemoteSource(
- relays: 'bootstrap',
- background: false,
- stream: false,
+ unawaited(
+ storage.query(
+ RequestFilter(authors: {pubkey}).toRequest(),
+ source: const RemoteSource(
+ relays: 'bootstrap',
+ background: false,
+ stream: false,
+ ),
),
);
}
diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart
new file mode 100644
index 0000000..b3713e2
--- /dev/null
+++ b/lib/services/background_update_service.dart
@@ -0,0 +1,289 @@
+import 'dart:io' show Platform;
+
+import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+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:purplebase/purplebase.dart';
+import 'package:workmanager/workmanager.dart';
+import 'package:zapstore/services/package_manager/android_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/utils/extensions.dart';
+
+/// Unique task name for background update checking
+const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck';
+
+/// Unique task identifier
+const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
+
+/// Notification channel for update notifications
+const kUpdateNotificationChannelId = 'zapstore_updates';
+const kUpdateNotificationChannelName = 'App Updates';
+const kUpdateNotificationChannelDescription =
+ 'Notifications for available app updates';
+
+/// The entry point for WorkManager background tasks.
+/// This MUST be a top-level function (not a class method).
+@pragma('vm:entry-point')
+void callbackDispatcher() {
+ Workmanager().executeTask((task, inputData) async {
+ switch (task) {
+ case kBackgroundUpdateTaskName:
+ return await _checkForUpdatesInBackground();
+ default:
+ return Future.value(false);
+ }
+ });
+}
+
+/// Background update check logic - runs in a separate isolate
+Future _checkForUpdatesInBackground() async {
+ try {
+ // Create a fresh provider container for background work
+ final container = ProviderContainer(
+ overrides: [
+ storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
+ packageManagerProvider.overrideWith(
+ (ref) => Platform.isAndroid
+ ? AndroidPackageManager(ref)
+ : DummyPackageManager(ref),
+ ),
+ ],
+ );
+
+ try {
+ // Initialize Purplebase with same DB path as main app
+ final dir = await getApplicationDocumentsDirectory();
+ final dbPath = path.join(dir.path, 'zapstore.db');
+
+ await container.read(
+ initializationProvider(
+ StorageConfiguration(
+ databasePath: dbPath,
+ defaultRelays: {
+ 'AppCatalog': {'wss://relay.zapstore.dev'},
+ },
+ ),
+ ).future,
+ );
+
+ // Get installed packages directly from Android system
+ final packageManager = container.read(packageManagerProvider.notifier);
+ await packageManager.syncInstalledPackages();
+
+ final packages = container.read(packageManagerProvider);
+ if (packages.isEmpty) {
+ return true; // No installed apps to check
+ }
+
+ final installedIds = packages.map((p) => p.appId).toSet();
+
+ // Query for apps with updates from relay
+ final storage = container.read(storageNotifierProvider.notifier);
+ final apps = await storage.query(
+ RequestFilter(
+ tags: {
+ '#d': installedIds,
+ '#f': {packageManager.platform},
+ },
+ ).toRequest(),
+ source: const RemoteSource(
+ relays: 'AppCatalog',
+ stream: false,
+ background: false,
+ ),
+ );
+
+ // Load releases for hasUpdate check
+ for (final app in apps) {
+ await storage.query(
+ RequestFilter(
+ tags: {
+ '#a': {app.event.addressableId},
+ },
+ ).toRequest(),
+ source: const LocalAndRemoteSource(
+ relays: 'AppCatalog',
+ stream: false,
+ background: false,
+ ),
+ );
+ }
+
+ // Re-query apps from local to ensure relationships are loaded
+ final appsWithRelations = await storage.query(
+ RequestFilter(
+ tags: {
+ '#d': installedIds,
+ '#f': {packageManager.platform},
+ },
+ ).toRequest(),
+ source: const LocalSource(),
+ );
+
+ // Count updates
+ int updateCount = 0;
+ final updatableAppNames = [];
+
+ for (final app in appsWithRelations) {
+ if (app.hasUpdate) {
+ updateCount++;
+ updatableAppNames.add(app.name ?? app.identifier);
+ }
+ }
+
+ // Show notification if updates found
+ if (updateCount > 0) {
+ await _showUpdateNotification(updateCount, updatableAppNames);
+ }
+
+ return true;
+ } finally {
+ container.dispose();
+ }
+ } catch (e) {
+ // Background task failed - return false to indicate failure
+ // WorkManager may retry based on configuration
+ return false;
+ }
+}
+
+/// Show a local notification for available updates
+Future _showUpdateNotification(
+ int updateCount,
+ List appNames,
+) async {
+ final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
+
+ // Initialize notifications (needed in background isolate)
+ const initializationSettingsAndroid = AndroidInitializationSettings(
+ '@mipmap/ic_launcher',
+ );
+ const initializationSettings = InitializationSettings(
+ android: initializationSettingsAndroid,
+ );
+ await flutterLocalNotificationsPlugin.initialize(initializationSettings);
+
+ // Build notification content
+ final title = updateCount == 1
+ ? '1 app update available'
+ : '$updateCount app updates available';
+
+ final body = appNames.length <= 3
+ ? appNames.join(', ')
+ : '${appNames.take(3).join(', ')} and ${appNames.length - 3} more';
+
+ 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(
+ 0, // Notification ID
+ title,
+ body,
+ notificationDetails,
+ );
+}
+
+/// Service for managing background update checks
+class BackgroundUpdateService {
+ BackgroundUpdateService(this.ref);
+
+ final Ref ref;
+
+ /// Initialize WorkManager and register periodic task
+ Future initialize() async {
+ if (!Platform.isAndroid) {
+ // WorkManager only works on Android/iOS, skip on other platforms
+ return;
+ }
+
+ // Initialize WorkManager
+ await Workmanager().initialize(callbackDispatcher);
+
+ // Initialize local notifications
+ await _initializeNotifications();
+
+ // Register periodic task (minimum 15 minutes on Android)
+ // We use 6 hours for battery efficiency
+ await Workmanager().registerPeriodicTask(
+ kBackgroundUpdateTaskId,
+ kBackgroundUpdateTaskName,
+ frequency: const Duration(hours: 6),
+ constraints: Constraints(
+ networkType: NetworkType.connected,
+ requiresBatteryNotLow: true,
+ ),
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
+ backoffPolicy: BackoffPolicy.exponential,
+ initialDelay: const Duration(minutes: 15),
+ );
+ }
+
+ /// Initialize local notifications plugin
+ Future _initializeNotifications() async {
+ final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
+
+ const initializationSettingsAndroid = AndroidInitializationSettings(
+ '@mipmap/ic_launcher',
+ );
+ const initializationSettings = InitializationSettings(
+ android: initializationSettingsAndroid,
+ );
+
+ await flutterLocalNotificationsPlugin.initialize(
+ initializationSettings,
+ onDidReceiveNotificationResponse: (response) {
+ // Notification tapped - app will open to main screen
+ // Navigation is handled by the app's normal launch flow
+ },
+ );
+
+ // Create notification channel on Android
+ final androidPlugin =
+ flutterLocalNotificationsPlugin
+ .resolvePlatformSpecificImplementation<
+ AndroidFlutterLocalNotificationsPlugin
+ >();
+
+ if (androidPlugin != null) {
+ await androidPlugin.createNotificationChannel(
+ const AndroidNotificationChannel(
+ kUpdateNotificationChannelId,
+ kUpdateNotificationChannelName,
+ description: kUpdateNotificationChannelDescription,
+ importance: Importance.defaultImportance,
+ ),
+ );
+ }
+ }
+
+ /// Cancel background update checks
+ Future cancelBackgroundChecks() async {
+ await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId);
+ }
+
+ /// Trigger an immediate background check (for testing)
+ Future triggerImmediateCheck() async {
+ await Workmanager().registerOneOffTask(
+ '${kBackgroundUpdateTaskId}_immediate',
+ kBackgroundUpdateTaskName,
+ constraints: Constraints(networkType: NetworkType.connected),
+ );
+ }
+}
+
+/// Provider for the background update service
+final backgroundUpdateServiceProvider = Provider(
+ BackgroundUpdateService.new,
+);
diff --git a/lib/services/profile_service.dart b/lib/services/profile_service.dart
index 2ccb4b8..b4b17a5 100644
--- a/lib/services/profile_service.dart
+++ b/lib/services/profile_service.dart
@@ -172,31 +172,32 @@ final profileServiceProvider = Provider((ref) => ProfileService(ref));
/// - Returns local data immediately if available
/// - Reacts to profile updates from batch fetches via trigger mechanism
/// - Streams updates when profile data changes
-final profileProvider =
- AutoDisposeProvider.family, String>((ref, pubkey) {
- // Watch update trigger to react when profiles are saved by fetchProfiles()
- ref.watch(_profileUpdateTriggerProvider.select((state) => state[pubkey]));
+final profileProvider = AutoDisposeProvider.family, String>(
+ (ref, pubkey) {
+ // Watch update trigger to react when profiles are saved by fetchProfiles()
+ ref.watch(_profileUpdateTriggerProvider.select((state) => state[pubkey]));
- // Use query with LocalAndRemoteSource for reactive updates
- final profileState = ref.watch(
- query(
- authors: {pubkey},
- source: LocalAndRemoteSource(
- relays: {'social', 'vertex'},
- stream: true,
- background: true, // Don't block on remote
- ),
+ // Use query with LocalAndRemoteSource for reactive updates
+ final profileState = ref.watch(
+ query(
+ authors: {pubkey},
+ source: LocalAndRemoteSource(
+ relays: {'social', 'vertex'},
+ stream: true,
+ background: true, // Don't block on remote
),
- );
+ ),
+ );
- return switch (profileState) {
- StorageLoading() => const AsyncValue.loading(),
- StorageError(:final exception) => AsyncValue.error(
- exception,
- StackTrace.current,
- ),
- StorageData(:final models) => AsyncValue.data(
- models.isNotEmpty ? models.first : null,
- ),
- };
- });
+ return switch (profileState) {
+ StorageLoading() => const AsyncValue.loading(),
+ StorageError(:final exception) => AsyncValue.error(
+ exception,
+ StackTrace.current,
+ ),
+ StorageData(:final models) => AsyncValue.data(
+ models.isNotEmpty ? models.first : null,
+ ),
+ };
+ },
+);
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 8805b61..763252e 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,6 +7,7 @@ import Foundation
import app_links
import connectivity_plus
+import flutter_local_notifications
import flutter_secure_storage_darwin
import path_provider_foundation
import share_plus
@@ -18,6 +19,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
+ FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index e417a23..a39eec7 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -375,6 +375,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
+ flutter_local_notifications:
+ dependency: "direct main"
+ description:
+ name: flutter_local_notifications
+ sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
+ url: "https://pub.dev"
+ source: hosted
+ version: "19.5.0"
+ flutter_local_notifications_linux:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_linux
+ sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_local_notifications_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_platform_interface
+ sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.1.0"
+ flutter_local_notifications_windows:
+ dependency: transitive
+ description:
+ name: flutter_local_notifications_windows
+ sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.3"
flutter_markdown:
dependency: "direct main"
description:
@@ -1149,6 +1181,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.4"
+ timezone:
+ dependency: transitive
+ description:
+ name: timezone
+ sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.1"
toastification:
dependency: "direct main"
description:
@@ -1309,6 +1349,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
+ workmanager:
+ dependency: "direct main"
+ description:
+ name: workmanager
+ sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.0+3"
+ workmanager_android:
+ dependency: transitive
+ description:
+ name: workmanager_android
+ sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.0+2"
+ workmanager_apple:
+ dependency: transitive
+ description:
+ name: workmanager_apple
+ sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+2"
+ workmanager_platform_interface:
+ dependency: transitive
+ description:
+ name: workmanager_platform_interface
+ sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+1"
xdg_directories:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index b53c3ae..8d2ddf3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -54,6 +54,8 @@ dependencies:
app_links: ^6.4.1
flutter_secure_storage: ^10.0.0
markdown: ^7.3.0
+ workmanager: ^0.9.0+3
+ flutter_local_notifications: ^19.5.0
dependency_overrides:
models: