From 80da521dd6945be1aaddc9f301e98ad6b9bc6e80 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 19 Feb 2026 10:27:50 -0300 Subject: [PATCH 1/8] fix(fcm): wake background service on FCM push and start if dead - Add fcm-wake handler to background service to acknowledge wake signals - When FCM push arrives and service is running, send fcm-wake signal - When service is dead, start it and send app settings from SharedPreferences - Add 3-second timeout with 100ms polling to wait for service initialization - Set fcm.pending_fetch flag before starting service --- lib/background/background.dart | 6 ++++++ lib/services/fcm_service.dart | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index 994e9b78..a977f411 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -29,6 +29,12 @@ Future serviceMain(ServiceInstance service) async { 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; diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index fbaa7a6e..7089d9cc 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -1,9 +1,11 @@ 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/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/firebase_options.dart'; @@ -45,8 +47,32 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { final service = FlutterBackgroundService(); final isRunning = await service.isRunning(); - if (!isRunning) { + 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 await sharedPrefs.setBool('fcm.pending_fetch', true); + await service.startService(); + + final settingsJson = await sharedPrefs.getString( + SharedPreferencesKeys.appSettings.value, + ); + if (settingsJson != null) { + // Wait briefly for service to initialize (FCM handlers must complete quickly) + const maxWait = Duration(seconds: 3); + final deadline = DateTime.now().add(maxWait); + while (!(await service.isRunning())) { + if (DateTime.now().isAfter(deadline)) break; + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (await service.isRunning()) { + service.invoke('start', { + 'settings': jsonDecode(settingsJson), + }); + } + } } } catch (e) { debugPrint('FCM: background service error: $e'); From fe970844d55d472acf7126e1df4971b6bc27e0cd Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Thu, 19 Feb 2026 11:21:37 -0300 Subject: [PATCH 2/8] fix(fcm): add error handling for settings JSON decode in background handler - Wrap jsonDecode in try-catch to prevent crashes on malformed settings - Only invoke service.start if both service is running AND settings decoded successfully - Add debug print for decode failures --- lib/services/fcm_service.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index 7089d9cc..5e313200 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -59,6 +59,13 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { SharedPreferencesKeys.appSettings.value, ); if (settingsJson != null) { + Map? settings; + try { + settings = jsonDecode(settingsJson) as Map?; + } catch (e) { + debugPrint('FCM: Failed to decode settings: $e'); + } + // Wait briefly for service to initialize (FCM handlers must complete quickly) const maxWait = Duration(seconds: 3); final deadline = DateTime.now().add(maxWait); @@ -67,9 +74,9 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Future.delayed(const Duration(milliseconds: 100)); } - if (await service.isRunning()) { + if (await service.isRunning() && settings != null) { service.invoke('start', { - 'settings': jsonDecode(settingsJson), + 'settings': settings, }); } } From 80ae0ccdf37c994d710bff390667a517dc06a1ad Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 24 Feb 2026 15:01:21 -0300 Subject: [PATCH 3/8] fix(background): register event handlers before opening database to prevent race condition - Move service.on() registrations above openMostroDatabase() call - Prevents losing 'start' events invoked by FCM handler during db initialization - Add comment explaining the ordering requirement - Keep db and eventStore initialization after handlers are registered --- lib/background/background.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index a977f411..ca0bf7c6 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -22,9 +22,9 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase('events.db'); - final eventStore = EventStorage(db: db); + // 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; }); @@ -56,6 +56,9 @@ Future serviceMain(ServiceInstance service) async { service.invoke('service-ready', {}); }); + final db = await openMostroDatabase('events.db'); + final eventStore = EventStorage(db: db); + service.on('update-settings').listen((data) async { if (data == null) return; From a318f376ee111853d260ce12dc05c3d79cf91e23 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 11 Mar 2026 08:19:35 -0300 Subject: [PATCH 4/8] fix(fcm): improve background service startup reliability and remove pending fetch flag --- lib/services/fcm_service.dart | 78 +++++++++++++++++------------------ 1 file changed, 38 insertions(+), 40 deletions(-) diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart index 5e313200..0cb34c7e 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -18,6 +18,10 @@ import 'package:mostro_mobile/firebase_options.dart'; /// - 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 not available (IsolateNameServer port may not be +/// registered). We use debugPrint as an exception to the logger convention. @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { try { @@ -52,39 +56,48 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { service.invoke('fcm-wake', {}); } else { // Service is dead — start it and send settings - await sharedPrefs.setBool('fcm.pending_fetch', true); - await service.startService(); + final started = await service.startService(); + if (!started) { + debugPrint('FCM: startService() returned false, aborting'); + return; + } final settingsJson = await sharedPrefs.getString( SharedPreferencesKeys.appSettings.value, ); - if (settingsJson != null) { - Map? settings; - try { - settings = jsonDecode(settingsJson) as Map?; - } catch (e) { - debugPrint('FCM: Failed to decode settings: $e'); - } - - // Wait briefly for service to initialize (FCM handlers must complete quickly) - const maxWait = Duration(seconds: 3); - final deadline = DateTime.now().add(maxWait); - while (!(await service.isRunning())) { - if (DateTime.now().isAfter(deadline)) break; - await Future.delayed(const Duration(milliseconds: 100)); - } - - if (await service.isRunning() && settings != null) { - service.invoke('start', { - 'settings': settings, - }); - } + if (settingsJson == null) { + debugPrint('FCM: No settings found, service started without relay config'); + return; + } + + Map? settings; + try { + settings = jsonDecode(settingsJson) as Map?; + } catch (e) { + debugPrint('FCM: Failed to decode settings: $e'); + } + + if (settings == null) return; + + // Wait for service to initialize with exponential backoff + // (FCM handlers must complete quickly, ~20s budget on Android) + var delay = const Duration(milliseconds: 100); + const maxWait = Duration(seconds: 3); + final deadline = DateTime.now().add(maxWait); + while (!(await service.isRunning())) { + if (DateTime.now().isAfter(deadline)) break; + await Future.delayed(delay); + delay *= 2; + } + + if (await service.isRunning()) { + service.invoke('start', { + 'settings': settings, + }); } } } catch (e) { debugPrint('FCM: background service error: $e'); - // Set pending flag as fallback - await sharedPrefs.setBool('fcm.pending_fetch', true); } } catch (e) { debugPrint('FCM: background handler error: $e'); @@ -153,9 +166,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) { @@ -252,18 +262,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 From 575ee3f6d59de92bf504e85b7b4b1ec27bb674d2 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 18 Mar 2026 19:51:33 -0300 Subject: [PATCH 5/8] fix(background): restore persisted subscription filters on service revival and improve FCM startup reliability --- lib/background/background.dart | 52 +++++++++++++++ lib/data/models/enums/storage_keys.dart | 3 +- lib/services/fcm_service.dart | 85 +++++++++++++++++-------- lib/services/lifecycle_manager.dart | 17 +++++ lib/services/logger_service.dart | 11 ++++ 5 files changed, 141 insertions(+), 27 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index ca0bf7c6..1d03e252 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'; @@ -23,6 +26,8 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); + bool initialized = false; + // 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) { @@ -41,6 +46,13 @@ Future serviceMain(ServiceInstance service) async { final settingsMap = data['settings']; if (settingsMap == null) return; + // Idempotent: skip if already initialized (e.g. duplicate invoke from + // both FCM handler retry and MobileBackgroundService.on('on-start')). + if (initialized) { + service.invoke('service-ready', {}); + return; + } + loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); logger = Logger( @@ -53,9 +65,49 @@ Future serviceMain(ServiceInstance service) async { 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 { + 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; service.invoke('service-ready', {}); }); + // 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'); final eventStore = EventStorage(db: db); 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 0cb34c7e..222fdad1 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -6,13 +6,14 @@ 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/data/models/enums/storage_keys.dart'; -import 'package:mostro_mobile/services/logger_service.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) @@ -20,8 +21,8 @@ import 'package:mostro_mobile/firebase_options.dart'; /// - 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 not available (IsolateNameServer port may not be -/// registered). We use debugPrint as an exception to the logger convention. +/// 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 { @@ -55,10 +56,28 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { // Service is already running — signal it that FCM detected new activity service.invoke('fcm-wake', {}); } else { - // Service is dead — start it and send settings + // 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(); + }); + final started = await service.startService(); if (!started) { - debugPrint('FCM: startService() returned false, aborting'); + backgroundLog('startService() returned false, aborting'); + await handlersSub.cancel(); + await readySub.cancel(); return; } @@ -66,7 +85,9 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { SharedPreferencesKeys.appSettings.value, ); if (settingsJson == null) { - debugPrint('FCM: No settings found, service started without relay config'); + backgroundLog('No settings found, service started without relay config'); + await handlersSub.cancel(); + await readySub.cancel(); return; } @@ -74,33 +95,45 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { try { settings = jsonDecode(settingsJson) as Map?; } catch (e) { - debugPrint('FCM: Failed to decode settings: $e'); + backgroundLog('Failed to decode settings: $e'); } - if (settings == null) return; - - // Wait for service to initialize with exponential backoff - // (FCM handlers must complete quickly, ~20s budget on Android) - var delay = const Duration(milliseconds: 100); - const maxWait = Duration(seconds: 3); - final deadline = DateTime.now().add(maxWait); - while (!(await service.isRunning())) { - if (DateTime.now().isAfter(deadline)) break; - await Future.delayed(delay); - delay *= 2; + if (settings == null) { + await handlersSub.cancel(); + await readySub.cancel(); + return; } - if (await service.isRunning()) { - service.invoke('start', { - 'settings': settings, - }); + // 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'); + } + await handlersSub.cancel(); + + // 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'); + } } + await readySub.cancel(); } } catch (e) { - debugPrint('FCM: background service error: $e'); + backgroundLog('background service error: $e'); } } catch (e) { - debugPrint('FCM: background handler error: $e'); + backgroundLog('background handler error: $e'); } } diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 98af3f13..8520d32e 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; @@ -102,6 +105,20 @@ class LifecycleManager extends WidgetsBindingObserver { 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(); + final prefs = SharedPreferencesAsync(); + 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); diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 3e37520c..13d83d83 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] $message'); +} + class IsolateLogOutput extends LogOutput { final SendPort? sendPort; From 4ac9da5a4954072aa401571b77979dcd2b800f53 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 18 Mar 2026 23:43:26 -0300 Subject: [PATCH 6/8] fix(background): prevent duplicate initialization and stop service on startup failures (coderabbit changes) --- lib/background/background.dart | 26 +++++++-- lib/services/fcm_service.dart | 95 +++++++++++++++++--------------- lib/services/logger_service.dart | 2 +- 3 files changed, 73 insertions(+), 50 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index 1d03e252..2232b4fd 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -25,8 +25,10 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); + 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). @@ -46,13 +48,25 @@ Future serviceMain(ServiceInstance service) async { final settingsMap = data['settings']; if (settingsMap == null) return; - // Idempotent: skip if already initialized (e.g. duplicate invoke from - // both FCM handler retry and MobileBackgroundService.on('on-start')). + // 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) { + await initInFlight!.future; + service.invoke('service-ready', {}); + return; + } + + // Claim the in-flight slot synchronously (before any await) so no + // other callback can enter the init path. + initInFlight = Completer(); + loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); logger = Logger( @@ -86,6 +100,8 @@ Future serviceMain(ServiceInstance service) async { 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); @@ -100,6 +116,7 @@ Future serviceMain(ServiceInstance service) async { } initialized = true; + initInFlight!.complete(); service.invoke('service-ready', {}); }); @@ -109,7 +126,7 @@ Future serviceMain(ServiceInstance service) async { service.invoke('handlers-registered', {}); final db = await openMostroDatabase('events.db'); - final eventStore = EventStorage(db: db); + eventStore = EventStorage(db: db); service.on('update-settings').listen((data) async { if (data == null) return; @@ -142,7 +159,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/services/fcm_service.dart b/lib/services/fcm_service.dart index 222fdad1..580124a8 100644 --- a/lib/services/fcm_service.dart +++ b/lib/services/fcm_service.dart @@ -73,61 +73,66 @@ Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { if (!serviceReady.isCompleted) serviceReady.complete(); }); - final started = await service.startService(); - if (!started) { - backgroundLog('startService() returned false, aborting'); - await handlersSub.cancel(); - await readySub.cancel(); - return; - } - - final settingsJson = await sharedPrefs.getString( - SharedPreferencesKeys.appSettings.value, - ); - if (settingsJson == null) { - backgroundLog('No settings found, service started without relay config'); - await handlersSub.cancel(); - await readySub.cancel(); - return; - } - - Map? settings; + bool serviceStarted = false; try { - settings = jsonDecode(settingsJson) as Map?; - } catch (e) { - backgroundLog('Failed to decode settings: $e'); - } + 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; + } - if (settings == null) { - await handlersSub.cancel(); - await readySub.cancel(); - return; - } + Map? settings; + try { + settings = jsonDecode(settingsJson) as Map?; + } catch (e) { + backgroundLog('Failed to decode settings: $e'); + } - // 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'); - } - await handlersSub.cancel(); + if (settings == null) { + service.invoke('stop'); + return; + } - // 4. Send start and wait for ack (service-ready). Retry once on timeout. - service.invoke('start', {'settings': settings}); + // 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'); + } - try { - await serviceReady.future.timeout(const Duration(seconds: 5)); - } catch (_) { - backgroundLog('No service-ready ack, retrying start'); + // 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: 3)); + await serviceReady.future.timeout(const Duration(seconds: 5)); } catch (_) { - backgroundLog('Service did not acknowledge start after retry'); + 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(); } - await readySub.cancel(); } } catch (e) { backgroundLog('background service error: $e'); diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 13d83d83..8110fdab 100644 --- a/lib/services/logger_service.dart +++ b/lib/services/logger_service.dart @@ -295,7 +295,7 @@ Logger get logger { /// 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] $message'); + debugPrint('[BackgroundIsolate] ${cleanMessage(message)}'); } class IsolateLogOutput extends LogOutput { From 7999c1626a35c45bb3612b54423ddac66f8f7b09 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 18 Mar 2026 23:56:07 -0300 Subject: [PATCH 7/8] fix(background): wrap initialization in try-catch and propagate failures to concurrent callers --- lib/background/background.dart | 100 ++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index 2232b4fd..dc2bcb55 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -58,7 +58,12 @@ Future serviceMain(ServiceInstance service) async { // This prevents two overlapping async inits when both FCM handler and // MobileBackgroundService fire start before the first one completes. if (initInFlight != null) { - await initInFlight!.future; + try { + await initInFlight!.future; + } catch (_) { + // First caller's init failed — don't ack readiness. + return; + } service.invoke('service-ready', {}); return; } @@ -67,57 +72,62 @@ Future serviceMain(ServiceInstance service) async { // other callback can enter the init path. initInFlight = Completer(); - loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); + try { + loggerSendPort = IsolateNameServer.lookupPortByName(logger_service.isolatePortName); - logger = Logger( - printer: logger_service.SimplePrinter(), - output: logger_service.IsolateLogOutput(loggerSendPort), - level: Level.debug, - ); + 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); + 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'); + // 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('Failed to restore background filters: $e'); + logger?.e('Background service initialization failed', error: e); + initInFlight!.completeError(e); } - - initialized = true; - initInFlight!.complete(); - service.invoke('service-ready', {}); }); // Signal that Dart handlers are registered and ready to receive events. From 503972b957e0e938e70aaa11774066bce3d05d3e Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Fri, 20 Mar 2026 18:36:45 -0300 Subject: [PATCH 8/8] feat(background): persist and restore subscription filters across service restarts --- lib/services/lifecycle_manager.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 8520d32e..ab2f6ac6 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -52,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); @@ -92,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); @@ -102,6 +106,8 @@ class LifecycleManager extends WidgetsBindingObserver { } } + final prefs = SharedPreferencesAsync(); + if (activeFilters.isNotEmpty) { _isInBackground = true; logger.i("Switching to background"); @@ -110,7 +116,6 @@ class LifecycleManager extends WidgetsBindingObserver { // if revived from a dead state (e.g. FCM wake after app is killed). try { final filterMaps = activeFilters.map((f) => f.toMap()).toList(); - final prefs = SharedPreferencesAsync(); await prefs.setString( SharedPreferencesKeys.backgroundFilters.value, jsonEncode(filterMaps), @@ -120,6 +125,7 @@ class LifecycleManager extends WidgetsBindingObserver { } subscriptionManager.unsubscribeAll(); + // Transfer active subscriptions to background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(false); @@ -127,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");