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: