diff --git a/lib/background/background.dart b/lib/background/background.dart index 994e9b78..dc2bcb55 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:isolate'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -11,6 +13,7 @@ import 'package:mostro_mobile/features/notifications/services/background_notific import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/services/logger_service.dart' as logger_service; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; bool isAppForeground = true; String currentLanguage = 'en'; @@ -22,34 +25,119 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase('events.db'); - final eventStore = EventStorage(db: db); + EventStorage? eventStore; + + bool initialized = false; + Completer? initInFlight; + // Register event handlers BEFORE awaiting database open to avoid + // losing events (e.g. 'start' invoked by FCM handler during db init). service.on('app-foreground-status').listen((data) { isAppForeground = data?['is-foreground'] ?? isAppForeground; }); + service.on('fcm-wake').listen((data) { + // Service is already running with active subscriptions. + // Nostr subscriptions will automatically receive new events. + logger?.d('FCM wake signal received - subscriptions already active'); + }); + service.on('start').listen((data) async { if (data == null) return; final settingsMap = data['settings']; if (settingsMap == null) return; - loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); + // Already fully initialized — just ack. + if (initialized) { + service.invoke('service-ready', {}); + return; + } + + // Another start callback is already initializing — await it and ack. + // This prevents two overlapping async inits when both FCM handler and + // MobileBackgroundService fire start before the first one completes. + if (initInFlight != null) { + try { + await initInFlight!.future; + } catch (_) { + // First caller's init failed — don't ack readiness. + return; + } + service.invoke('service-ready', {}); + return; + } - logger = Logger( - printer: logger_service.SimplePrinter(), - output: logger_service.IsolateLogOutput(loggerSendPort), - level: Level.debug, - ); + // Claim the in-flight slot synchronously (before any await) so no + // other callback can enter the init path. + initInFlight = Completer(); - final settings = Settings.fromJson(settingsMap); - currentLanguage = settings.selectedLanguage ?? PlatformDispatcher.instance.locale.languageCode; - await nostrService.init(settings); + try { + loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); - service.invoke('service-ready', {}); + logger = Logger( + printer: logger_service.SimplePrinter(), + output: logger_service.IsolateLogOutput(loggerSendPort), + level: Level.debug, + ); + + final settings = Settings.fromJson(settingsMap); + currentLanguage = settings.selectedLanguage ?? PlatformDispatcher.instance.locale.languageCode; + await nostrService.init(settings); + + // Restore persisted subscription filters so the background service can + // do useful work even when revived from a dead state (e.g. FCM wake + // after app kill — no LifecycleManager to transfer subscriptions). + try { + final prefs = SharedPreferencesAsync(); + final filtersJson = await prefs.getString( + SharedPreferencesKeys.backgroundFilters.value, + ); + if (filtersJson != null) { + final filterList = jsonDecode(filtersJson) as List; + if (filterList.isNotEmpty) { + final request = NostrRequestX.fromJson(filterList); + final subscription = nostrService.subscribeToEvents(request); + + activeSubscriptions[request.subscriptionId!] = { + 'filters': filterList, + 'subscription': subscription, + }; + + subscription.listen((event) async { + try { + final store = eventStore; + if (store != null && await store.hasItem(event.id!)) return; + await notification_service.retryNotification(event); + } catch (e) { + logger?.e('Error processing restored subscription event', error: e); + } + }); + + logger?.i('Restored ${filterList.length} persisted background filters'); + } + } + } catch (e) { + logger?.e('Failed to restore background filters: $e'); + } + + initialized = true; + initInFlight!.complete(); + service.invoke('service-ready', {}); + } catch (e) { + logger?.e('Background service initialization failed', error: e); + initInFlight!.completeError(e); + } }); + // Signal that Dart handlers are registered and ready to receive events. + // Sent before database open so callers (e.g. FCM handler) can safely + // invoke('start') without it being dropped. + service.invoke('handlers-registered', {}); + + final db = await openMostroDatabase('events.db'); + eventStore = EventStorage(db: db); + service.on('update-settings').listen((data) async { if (data == null) return; @@ -81,7 +169,8 @@ Future serviceMain(ServiceInstance service) async { subscription.listen((event) async { try { - if (await eventStore.hasItem(event.id!)) { + final store = eventStore; + if (store != null && await store.hasItem(event.id!)) { return; } await notification_service.retryNotification(event); diff --git a/lib/data/models/enums/storage_keys.dart b/lib/data/models/enums/storage_keys.dart index ac3f1b2c..2b8f8545 100644 --- a/lib/data/models/enums/storage_keys.dart +++ b/lib/data/models/enums/storage_keys.dart @@ -4,7 +4,8 @@ enum SharedPreferencesKeys { fullPrivacy('full_privacy'), firstRunComplete('first_run_complete'), mostroCustomNodes('mostro_custom_nodes'), - trustedNodeMetadata('trusted_node_metadata'); + trustedNodeMetadata('trusted_node_metadata'), + backgroundFilters('background_filters'); final String value; diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index fbaa7a6e..580124a8 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -1,21 +1,28 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:mostro_mobile/services/logger_service.dart' + show backgroundLog, logger; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/firebase_options.dart'; -/// FCM background message handler - wakes up the app to process new events -/// This is called when the app is in background or terminated +/// FCM background message handler - wakes up the app to process new events. +/// This is called when the app is in background or terminated. /// /// The handler follows MIP-05 approach: /// - FCM sends silent/empty notifications (no content) /// - This handler wakes up the app /// - The existing background service handles fetching and processing events +/// +/// NOTE: This handler runs in a separate Dart isolate where the project's +/// logger singleton is unavailable. All logging goes through backgroundLog() +/// (see logger_service.dart) instead of the main logger. @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { try { @@ -45,16 +52,93 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { final service = FlutterBackgroundService(); final isRunning = await service.isRunning(); - if (!isRunning) { - await sharedPrefs.setBool('fcm.pending_fetch', true); + if (isRunning) { + // Service is already running — signal it that FCM detected new activity + service.invoke('fcm-wake', {}); + } else { + // Service is dead — start it and send settings. + // We set up listeners BEFORE startService() so we never miss events + // that the background isolate emits during its synchronous setup. + + // 1. Listen for handlers-registered (emitted by serviceMain right + // after it registers its event handlers, before opening the DB). + final handlersReady = Completer(); + final handlersSub = service.on('handlers-registered').listen((_) { + if (!handlersReady.isCompleted) handlersReady.complete(); + }); + + // 2. Listen for service-ready (emitted after NostrService.init()). + final serviceReady = Completer(); + final readySub = service.on('service-ready').listen((_) { + if (!serviceReady.isCompleted) serviceReady.complete(); + }); + + bool serviceStarted = false; + try { + final started = await service.startService(); + if (!started) { + backgroundLog('startService() returned false, aborting'); + return; + } + serviceStarted = true; + + final settingsJson = await sharedPrefs.getString( + SharedPreferencesKeys.appSettings.value, + ); + if (settingsJson == null) { + backgroundLog('No settings found, stopping service'); + service.invoke('stop'); + return; + } + + Map? settings; + try { + settings = jsonDecode(settingsJson) as Map?; + } catch (e) { + backgroundLog('Failed to decode settings: $e'); + } + + if (settings == null) { + service.invoke('stop'); + return; + } + + // 3. Wait for handlers to be registered (guarantees on('start') is + // active so our invoke won't be dropped). + try { + await handlersReady.future.timeout(const Duration(seconds: 5)); + } catch (_) { + backgroundLog('Timeout waiting for handlers-registered, proceeding anyway'); + } + + // 4. Send start and wait for ack (service-ready). Retry once on timeout. + service.invoke('start', {'settings': settings}); + + try { + await serviceReady.future.timeout(const Duration(seconds: 5)); + } catch (_) { + backgroundLog('No service-ready ack, retrying start'); + service.invoke('start', {'settings': settings}); + try { + await serviceReady.future.timeout(const Duration(seconds: 3)); + } catch (_) { + backgroundLog('Service did not acknowledge start after retry, stopping'); + service.invoke('stop'); + } + } + } catch (e) { + backgroundLog('Error during service startup: $e'); + if (serviceStarted) service.invoke('stop'); + } finally { + await handlersSub.cancel(); + await readySub.cancel(); + } } } catch (e) { - debugPrint('FCM: background service error: $e'); - // Set pending flag as fallback - await sharedPrefs.setBool('fcm.pending_fetch', true); + backgroundLog('background service error: $e'); } } catch (e) { - debugPrint('FCM: background handler error: $e'); + backgroundLog('background handler error: $e'); } } @@ -120,9 +204,6 @@ class FCMService { // Register background message handler FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); - // Check for pending fetch from previous background wake - await _checkPendingFetch(); - _isInitialized = true; debugPrint('FCM: Initialized successfully'); } catch (e, stackTrace) { @@ -219,18 +300,6 @@ class FCMService { ); } - Future _checkPendingFetch() async { - try { - final hasPending = await _prefs.getBool('fcm.pending_fetch') ?? false; - if (hasPending) { - await _prefs.setBool('fcm.pending_fetch', false); - // The background service will handle fetching when it starts - } - } catch (e) { - logger.e('Error checking pending fetch: $e'); - } - } - Future getToken() async { try { // Try to get from storage first diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 98af3f13..ab2f6ac6 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -1,8 +1,10 @@ +import 'dart:convert'; import 'dart:io'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_type.dart'; @@ -11,6 +13,7 @@ import 'package:mostro_mobile/shared/providers/background_service_provider.dart' import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_manager_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class LifecycleManager extends WidgetsBindingObserver { final Ref ref; @@ -49,6 +52,10 @@ class LifecycleManager extends WidgetsBindingObserver { _isInBackground = false; logger.i("Switching to foreground"); + // Clear persisted background filters since foreground takes over + final prefs = SharedPreferencesAsync(); + await prefs.remove(SharedPreferencesKeys.backgroundFilters.value); + // Stop background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(true); @@ -89,7 +96,7 @@ class LifecycleManager extends WidgetsBindingObserver { // Get the subscription manager final subscriptionManager = ref.read(subscriptionManagerProvider); final activeFilters = []; - + // Get actual filters for each subscription type for (final type in SubscriptionType.values) { final filters = subscriptionManager.getActiveFilters(type); @@ -99,10 +106,26 @@ class LifecycleManager extends WidgetsBindingObserver { } } + final prefs = SharedPreferencesAsync(); + if (activeFilters.isNotEmpty) { _isInBackground = true; logger.i("Switching to background"); + + // Persist filters so the background service can restore subscriptions + // if revived from a dead state (e.g. FCM wake after app is killed). + try { + final filterMaps = activeFilters.map((f) => f.toMap()).toList(); + await prefs.setString( + SharedPreferencesKeys.backgroundFilters.value, + jsonEncode(filterMaps), + ); + } catch (e) { + logger.e('Failed to persist background filters: $e'); + } + subscriptionManager.unsubscribeAll(); + // Transfer active subscriptions to background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(false); @@ -110,7 +133,11 @@ class LifecycleManager extends WidgetsBindingObserver { "Transferring ${activeFilters.length} active filters to background service"); backgroundService.subscribe(activeFilters); } else { + _isInBackground = true; logger.w("No active subscriptions to transfer to background service"); + // Clear any previously persisted filters to prevent stale subscriptions + // on service revival + await prefs.remove(SharedPreferencesKeys.backgroundFilters.value); } logger.i("Background transition complete"); diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 3e37520c..8110fdab 100644 --- a/lib/services/logger_service.dart +++ b/lib/services/logger_service.dart @@ -287,6 +287,17 @@ Logger get logger { return _cachedLogger!; } +/// Log wrapper for isolated Dart entry points (e.g., FCM background handler) +/// where the main logger singleton is unavailable because the +/// IsolateNameServer port has not been registered. +/// +/// All background-isolate logging should go through this function instead of +/// calling debugPrint directly, so the convention is centralized and easy to +/// upgrade if cross-isolate logging becomes possible in the future. +void backgroundLog(String message) { + debugPrint('[BackgroundIsolate] ${cleanMessage(message)}'); +} + class IsolateLogOutput extends LogOutput { final SendPort? sendPort;