import 'dart:async'; import 'dart:io' show Directory, File, Platform; import 'dart:isolate'; import 'dart:ui' as ui; import 'dart:ui' show PlatformDispatcher; import 'package:background_downloader/background_downloader.dart' hide Request; import 'package:flutter/foundation.dart' show FlutterError, kDebugMode; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/widgets.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:go_router/go_router.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/router.dart'; import 'package:zapstore/services/log_service.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/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; /// Unique task name for background update checking const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; /// Unique task name for weekly cleanup const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; /// Unique task identifier const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; /// Unique task identifier for weekly cleanup const kWeeklyCleanupTaskId = 'weeklyCleanup'; /// Notification channel for update notifications const kUpdateNotificationChannelId = 'zapstore_updates'; const kUpdateNotificationChannelName = 'App Updates'; const kUpdateNotificationChannelDescription = 'Notifications for available app updates'; /// Stale download threshold for cleanup const _staleDownloadThreshold = Duration(days: 7); /// How long user must be inactive before showing background notification const _inactivityThreshold = Duration(hours: 24); /// Notification payload for deep linking to updates screen const _kNotificationPayload = 'updates'; /// Input data key for AppCatalog relay URLs const kAppCatalogRelaysKey = 'appCatalogRelays'; /// Holds the isolate error port for the workmanager background /// isolate. Top-level so the GC cannot collect the port while the /// task is running. // ignore: unused_element RawReceivePort? _workmanagerErrorPort; /// The entry point for WorkManager background tasks. /// This MUST be a top-level function (not a class method). @pragma('vm:entry-point') void callbackDispatcher() { WidgetsFlutterBinding.ensureInitialized(); ui.DartPluginRegistrant.ensureInitialized(); // Wire all four error sinks for this background isolate. // LogService.init is fire-and-forget — pre-init writes go to the // ring buffer and are flushed once disk is ready. unawaited(LogService.I.init(isolateName: 'workmanager')); FlutterError.onError = (details) { FlutterError.presentError(details); LogService.I.fatal( 'uncaught error', tag: 'crash', fields: const {'source': 'flutter'}, err: details.exception, stack: details.stack, ); LogService.I.flushSync(); }; PlatformDispatcher.instance.onError = (error, stack) { LogService.I.fatal( 'uncaught error', tag: 'crash', fields: const {'source': 'platform_dispatcher'}, err: error, stack: stack, ); LogService.I.flushSync(); return true; }; final port = RawReceivePort((dynamic pair) { if (pair is List && pair.length == 2) { final err = pair[0]?.toString() ?? 'unknown'; final stack = pair[1] == null ? null : StackTrace.fromString(pair[1].toString()); LogService.I.fatal( 'uncaught error', tag: 'crash', fields: const {'source': 'isolate'}, err: err, stack: stack, ); LogService.I.flushSync(); } }); Isolate.current.addErrorListener(port.sendPort); _workmanagerErrorPort = port; runZonedGuarded(() { Workmanager().executeTask((task, inputData) async { try { switch (task) { case kBackgroundUpdateTaskName: final relayUrls = (inputData?[kAppCatalogRelaysKey] as List?) ?.cast() .toSet(); return await _checkForUpdatesInBackground(relayUrls); case kWeeklyCleanupTaskName: return await _performWeeklyCleanup(); default: return false; } } catch (e, st) { LogService.I.error( 'background task failed', tag: 'workmanager', fields: {'task': task}, err: e, stack: st, ); return false; } finally { // Ensure entries from this task hit disk before the isolate // tears down. await LogService.I.flush(); } }); }, (error, stack) { LogService.I.fatal( 'uncaught error', tag: 'crash', fields: const {'source': 'zone'}, err: error, stack: stack, ); LogService.I.flushSync(); }); } /// Perform weekly cleanup of stale downloads Future _performWeeklyCleanup() async { try { final downloader = FileDownloader(); // Get all tracked records from background_downloader's database final trackedRecords = await downloader.database.allRecords( group: FileDownloader.defaultGroup, ); for (final record in trackedRecords) { final task = record.task; if (task is! DownloadTask) continue; final taskAge = DateTime.now().difference(record.task.creationTime); if (taskAge > _staleDownloadThreshold) { try { // Cancel if active await downloader.cancelTaskWithId(task.taskId); } catch (_) {} try { // Delete the file if it exists final filePath = await task.filePath(); final file = File(filePath); if (await file.exists()) { await file.delete(); } } catch (_) {} try { // Remove from database await downloader.database.deleteRecordWithId(task.taskId); } catch (_) {} } } // Also clean up orphaned APK files in download directory try { final cacheDir = await getApplicationCacheDirectory(); final downloadDir = Directory( path.join(cacheDir.path, 'flutter_background_downloader'), ); if (await downloadDir.exists()) { final entities = downloadDir.listSync(); final cutoff = DateTime.now().subtract(_staleDownloadThreshold); for (final entity in entities) { if (entity is File) { final stat = await entity.stat(); if (stat.modified.isBefore(cutoff)) { await entity.delete(); } } } } } catch (_) { // Ignore cleanup failures for orphaned files } return true; } catch (e) { // Cleanup failed - return false for retry return false; } } /// Background update check logic - runs in a separate isolate via WorkManager. /// /// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to /// default relay if not provided. Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { try { final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'}; final container = ProviderContainer( overrides: [ storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), packageManagerProvider.overrideWith( (ref) => Platform.isAndroid ? BackgroundPackageManager(ref) : DummyPackageManager(ref), ), ], ); try { final dir = await getApplicationSupportDirectory(); final dbPath = path.join(dir.path, 'zapstore.db'); await container.read( initializationProvider( StorageConfiguration( databasePath: dbPath, defaultRelays: {'AppCatalog': relays}, ), ).future, ); final packageManager = container.read(packageManagerProvider.notifier); await packageManager.syncInstalledPackages(); final pmState = container.read(packageManagerProvider); if (pmState.installed.isEmpty) { return true; } final storage = container.read(storageNotifierProvider.notifier); final catalog = await fetchCatalog( storage: storage, installedIds: pmState.installed.keys.toSet(), platform: packageManager.platform, subscriptionPrefix: 'app-bg', ); final updatableInstallables = {}; for (final entry in catalog.installableByApp.entries) { if (packageManager.hasUpdate(entry.key, entry.value)) { updatableInstallables[entry.key] = entry.value; } } if (updatableInstallables.isNotEmpty) { final updatableApps = await storage.query( RequestFilter( tags: { '#d': updatableInstallables.keys.toSet(), '#f': {packageManager.platform}, }, ).toRequest(), source: const LocalSource(), ); await _showUpdateNotificationIfNeeded( updatableApps, updatableInstallables, ); } return true; } finally { container.dispose(); } } catch (e) { return false; } } /// Show a local notification for available updates. /// Only notifies if: /// 1. User hasn't opened app in 24+ hours /// 2. There are updates with installable.createdAt > seenUntil AND > lastOpened /// (new since both last notification AND last time user saw the app) /// /// [installables] maps app identifier → the installable (SoftwareAsset or /// FileMetadata) that represents the available update. Its `createdAt` is used /// for freshness checks instead of `app.latestRelease.value` which is not /// populated in the background isolate context. Future _showUpdateNotificationIfNeeded( List updates, Map installables, ) async { final settingsService = SettingsService(); final settings = await settingsService.load(); // Skip if user recently opened the app if (settings.lastAppOpened != null && DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) { return; } // Get the "seen until" timestamp - updates with createdAt > this are new final seenUntil = settings.seenUntil; // Filter to only updates that are genuinely new: // - installable.createdAt > seenUntil (not already notified via background) // - installable.createdAt > lastOpened (not already seen when user opened app) // This prevents nagging about updates user saw in the app but chose to ignore final newUpdates = updates.where((app) { final installable = installables[app.identifier]; if (installable == null) return false; final releaseTime = installable.createdAt; // Must be newer than last notification (if any) if (seenUntil != null && !releaseTime.isAfter(seenUntil)) { return false; } // Must be newer than last app open (if any) - user may have seen it in UI if (settings.lastAppOpened != null && !releaseTime.isAfter(settings.lastAppOpened!)) { return false; } return true; }).toList(); if (newUpdates.isEmpty) { return; // No new updates to notify about } // Show the notification final plugin = FlutterLocalNotificationsPlugin(); const initSettings = InitializationSettings( android: AndroidInitializationSettings('@drawable/ic_notification'), ); await plugin.initialize(initSettings); await _ensureUpdateNotificationChannel(plugin); final appNames = newUpdates.map((a) => a.name ?? a.identifier).toList(); final title = newUpdates.length == 1 ? '1 app update available' : '${newUpdates.length} app updates available'; final body = appNames.length <= 3 ? appNames.join(', ') : '${appNames.take(3).join(', ')} and ${appNames.length - 3} more'; await plugin.show( 0, title, body, const NotificationDetails( android: AndroidNotificationDetails( kUpdateNotificationChannelId, kUpdateNotificationChannelName, channelDescription: kUpdateNotificationChannelDescription, importance: Importance.defaultImportance, priority: Priority.defaultPriority, showWhen: true, autoCancel: true, ), ), payload: _kNotificationPayload, ); // Update seenUntil to now - future checks will only notify about releases after this await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now())); } /// 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(); // Resolve AppCatalog relays from main isolate to pass to background task final appCatalogRelays = await ref .read(storageNotifierProvider.notifier) .resolveRelays('AppCatalog'); // Register periodic task (minimum 15 minutes on Android) // We use 24 hours to match the inactivity threshold for notifications. // More frequent checks would be wasted since we only notify users // who haven't opened the app in 24+ hours. await Workmanager().registerPeriodicTask( kBackgroundUpdateTaskId, kBackgroundUpdateTaskName, frequency: const Duration(hours: 24), constraints: Constraints( networkType: NetworkType.connected, requiresBatteryNotLow: true, ), existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, backoffPolicy: BackoffPolicy.exponential, initialDelay: const Duration(hours: 1), inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, ); // Register weekly cleanup task await Workmanager().registerPeriodicTask( kWeeklyCleanupTaskId, kWeeklyCleanupTaskName, frequency: const Duration(days: 7), constraints: Constraints( requiresBatteryNotLow: true, requiresCharging: true, // Only run when charging for cleanup ), existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, backoffPolicy: BackoffPolicy.exponential, initialDelay: const Duration(hours: 24), // Start first cleanup after 24h ); } /// Initialize local notifications plugin and request permission (release/profile only). Future _initializeNotifications() async { // Request notification permission on Android 13+ (API 33+); skip in debug / dev runs. if (Platform.isAndroid && !kDebugMode) { final status = await Permission.notification.status; if (!status.isGranted) { await Permission.notification.request(); } } final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); const initializationSettingsAndroid = AndroidInitializationSettings( '@drawable/ic_notification', ); const initializationSettings = InitializationSettings( android: initializationSettingsAndroid, ); await flutterLocalNotificationsPlugin.initialize( initializationSettings, onDidReceiveNotificationResponse: _handleNotificationTap, ); // Check if app was launched from a notification (terminated state) final launchDetails = await flutterLocalNotificationsPlugin .getNotificationAppLaunchDetails(); if (launchDetails?.didNotificationLaunchApp == true && launchDetails?.notificationResponse?.payload == _kNotificationPayload) { _navigateToUpdates(); } await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); } /// Handle notification tap - navigate to updates screen static void _handleNotificationTap(NotificationResponse response) { if (response.payload == _kNotificationPayload) { _navigateToUpdates(); } } /// Navigate to the updates screen static void _navigateToUpdates() { // Use the root navigator key to navigate final context = rootNavigatorKey.currentContext; if (context != null) { GoRouter.of(context).go('/updates'); } } /// Cancel background update checks Future cancelBackgroundChecks() async { await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId); } } Future _ensureUpdateNotificationChannel( FlutterLocalNotificationsPlugin plugin, ) async { final androidPlugin = plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); if (androidPlugin != null) { await androidPlugin.createNotificationChannel( const AndroidNotificationChannel( kUpdateNotificationChannelId, kUpdateNotificationChannelName, description: kUpdateNotificationChannelDescription, importance: Importance.defaultImportance, ), ); } } /// Provider for the background update service final backgroundUpdateServiceProvider = Provider( BackgroundUpdateService.new, );