From 7a5dfa1dc957318662f38df69f2e3a356e953d05 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 30 Dec 2025 01:02:19 -0300 Subject: [PATCH 1/3] feat: implement FCM service for push notification wake-up mechanism Add FCMService to handle Firebase Cloud Messaging integration following MIP-05 approach: - Implement background message handler to wake app when notifications arrive - Add FCM token management with automatic refresh handling - Set up foreground and background message listeners - Integrate with existing flutter_background_service for event processing - Add platform checks to skip Firebase on unsupported platforms (Linux, web) - Include --- lib/services/fcm_service.dart | 273 ++++++++++++++++++ .../providers/fcm_service_provider.dart | 8 + lib/shared/providers/providers.dart | 1 + 3 files changed, 282 insertions(+) create mode 100644 lib/services/fcm_service.dart create mode 100644 lib/shared/providers/fcm_service_provider.dart diff --git a/lib/services/fcm_service.dart b/lib/services/fcm_service.dart new file mode 100644 index 00000000..ec07b72a --- /dev/null +++ b/lib/services/fcm_service.dart @@ -0,0 +1,273 @@ +import 'dart:async'; +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:logger/logger.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mostro_mobile/firebase_options.dart'; + +final _logger = Logger(); + +/// 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 +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + try { + // Skip on unsupported platforms + if (kIsWeb || Platform.isLinux) return; + + // Initialize Firebase for background context + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + final sharedPrefs = SharedPreferencesAsync(); + + // Record wake timestamp for debugging and potential retry logic + final now = (DateTime.now().millisecondsSinceEpoch / 1000).floor(); + await sharedPrefs.setInt('fcm.last_wake_timestamp', now); + + // Try to communicate with the background service + // The existing flutter_background_service handles: + // - Fetching new messages from Nostr relays + // - Decrypting messages locally + // - Displaying local notifications + // - Updating badge count + try { + final service = FlutterBackgroundService(); + final isRunning = await service.isRunning(); + + if (!isRunning) { + await sharedPrefs.setBool('fcm.pending_fetch', true); + } + } 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'); + } +} + +class FCMService { + final FirebaseMessaging _messaging = FirebaseMessaging.instance; + final SharedPreferencesAsync _prefs; + + static const String _fcmTokenKey = 'fcm_token'; + static const Duration _tokenTimeout = Duration(seconds: 10); + + StreamSubscription? _tokenRefreshSubscription; + StreamSubscription? _foregroundMessageSubscription; + + bool _isInitialized = false; + + FCMService(this._prefs); + + bool get isInitialized => _isInitialized; + + Future initialize() async { + if (_isInitialized) return; + + try { + // Skip Firebase initialization on Linux (not supported) + if (!kIsWeb && Platform.isLinux) { + _logger + .i('Firebase not supported on Linux - skipping FCM initialization'); + return; + } + + // Initialize Firebase + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + // Request notification permissions + final permissionGranted = await _requestPermissions(); + if (!permissionGranted) { + _logger.w( + 'Notification permissions not granted - FCM will have limited functionality'); + } + + // Get and store FCM token + await _getAndStoreToken(); + + // Set up token refresh listener + _setupTokenRefreshListener(); + + // Set up foreground message listener + _setupForegroundMessageListener(); + + // 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) { + _logger.e('Error initializing FCM Service: $e'); + _logger.e('Stack trace: $stackTrace'); + // Don't rethrow - app should continue without FCM + } + } + + Future _requestPermissions() async { + try { + final settings = await _messaging.requestPermission( + alert: true, + announcement: false, + badge: true, + carPlay: false, + criticalAlert: false, + provisional: false, + sound: true, + ); + + final granted = + settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + + _logger + .i('Notification permission status: ${settings.authorizationStatus}'); + return granted; + } catch (e) { + _logger.e('Error requesting permissions: $e'); + return false; + } + } + + Future _getAndStoreToken() async { + try { + final token = await _messaging.getToken().timeout( + _tokenTimeout, + onTimeout: () { + _logger.w('Timeout getting FCM token'); + return null; + }, + ); + + if (token != null) { + await _prefs.setString(_fcmTokenKey, token); + debugPrint('FCM: Token obtained'); + return token; + } else { + _logger + .w('Failed to obtain FCM token - push notifications may not work'); + return null; + } + } catch (e) { + _logger.e('Error getting FCM token: $e'); + return null; + } + } + + void _setupTokenRefreshListener() { + _tokenRefreshSubscription?.cancel(); + + _tokenRefreshSubscription = _messaging.onTokenRefresh.listen( + (newToken) async { + debugPrint('FCM: Token refreshed'); + await _prefs.setString(_fcmTokenKey, newToken); + + // TODO: In Phase 3, send updated encrypted token to notification server + // This will be implemented in PushNotificationService + }, + onError: (error) { + _logger.e('Error on token refresh: $error'); + }, + ); + } + + void _setupForegroundMessageListener() { + _foregroundMessageSubscription?.cancel(); + + _foregroundMessageSubscription = FirebaseMessaging.onMessage.listen( + (RemoteMessage message) async { + // Silent notification received while app is in foreground + // The existing background service is already running and will + // handle fetching and displaying notifications + + // Optionally trigger an immediate refresh + try { + final service = FlutterBackgroundService(); + final isRunning = await service.isRunning(); + + if (isRunning) { + // The background service will pick up new events through its + // existing subscription mechanism + } + } catch (e) { + _logger.e('Error triggering background service: $e'); + } + }, + onError: (error) { + _logger.e('Error receiving foreground message: $error'); + }, + ); + } + + 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 + final storedToken = await _prefs.getString(_fcmTokenKey); + if (storedToken != null) { + return storedToken; + } + + // If not in storage, get from Firebase + return await _getAndStoreToken(); + } catch (e) { + _logger.e('Error getting token: $e'); + return null; + } + } + + Future deleteToken() async { + try { + await _messaging.deleteToken().timeout( + _tokenTimeout, + onTimeout: () { + _logger.w('Timeout deleting FCM token'); + }, + ); + await _prefs.remove(_fcmTokenKey); + } catch (e) { + _logger.e('Error deleting FCM token: $e'); + // Still try to remove from local storage + try { + await _prefs.remove(_fcmTokenKey); + } catch (localError) { + _logger.e('Error removing token from local storage: $localError'); + } + } + } + + void dispose() { + _tokenRefreshSubscription?.cancel(); + _foregroundMessageSubscription?.cancel(); + _tokenRefreshSubscription = null; + _foregroundMessageSubscription = null; + _isInitialized = false; + } +} diff --git a/lib/shared/providers/fcm_service_provider.dart b/lib/shared/providers/fcm_service_provider.dart new file mode 100644 index 00000000..7d76154f --- /dev/null +++ b/lib/shared/providers/fcm_service_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/services/fcm_service.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; + +final fcmServiceProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return FCMService(prefs); +}); diff --git a/lib/shared/providers/providers.dart b/lib/shared/providers/providers.dart index 851ff56b..a9407771 100644 --- a/lib/shared/providers/providers.dart +++ b/lib/shared/providers/providers.dart @@ -1,2 +1,3 @@ export 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; export 'package:mostro_mobile/shared/providers/storage_providers.dart'; +export 'package:mostro_mobile/shared/providers/fcm_service_provider.dart'; From e0bead76d4fbc51566cecfd1f2f7d9d3e10c3291 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 30 Dec 2025 01:03:00 -0300 Subject: [PATCH 2/3] feat: initialize FCM service on app startup with platform checks --- lib/main.dart | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/main.dart b/lib/main.dart index 0c46fdcd..83f2a226 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -8,6 +10,7 @@ import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/background/background_service.dart'; import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart'; +import 'package:mostro_mobile/services/fcm_service.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; @@ -37,6 +40,9 @@ Future main() async { final backgroundService = createBackgroundService(settings.settings); await backgroundService.init(); + // Initialize FCM (skip on Linux) + await _initializeFirebaseMessaging(sharedPreferences); + final container = ProviderContainer( overrides: [ settingsProvider.overrideWith((b) => settings), @@ -82,3 +88,22 @@ void _initializeTimeAgoLocalization() { // English is already the default, no need to set it } + +/// Initialize Firebase Cloud Messaging +Future _initializeFirebaseMessaging(SharedPreferencesAsync prefs) async { + try { + // Skip Firebase initialization on Linux (not supported) + if (!kIsWeb && Platform.isLinux) { + debugPrint( + 'Firebase not supported on Linux - skipping FCM initialization'); + return; + } + + final fcmService = FCMService(prefs); + await fcmService.initialize(); + debugPrint('Firebase Cloud Messaging initialized successfully'); + } catch (e) { + // Log error but don't crash app if FCM initialization fails + debugPrint('Failed to initialize Firebase Cloud Messaging: $e'); + } +} From 12a1d172a1604c8f65d5b1433462be1d52cf924c Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 30 Dec 2025 01:03:11 -0300 Subject: [PATCH 3/3] docs: update FCM implementation status to mark Phase 2 as complete --- docs/FCM_IMPLEMENTATION.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/FCM_IMPLEMENTATION.md b/docs/FCM_IMPLEMENTATION.md index 3a57e310..0ba3f6bc 100644 --- a/docs/FCM_IMPLEMENTATION.md +++ b/docs/FCM_IMPLEMENTATION.md @@ -104,7 +104,7 @@ This implementation follows the **MIP-05 (Marmot Push Notifications)** specifica The implementation is divided into phases to match MostroP2P's architecture while maintaining MIP-05 privacy principles: - **Phase 1:** Firebase basic configuration ✅ COMPLETE -- **Phase 2:** FCM service with background integration ⚠️ TO IMPLEMENT +- **Phase 2:** FCM service with background integration ✅ COMPLETE - **Phase 3:** Encrypted token registration with server ⚠️ TO IMPLEMENT - **Phase 4:** User settings and opt-out controls ⚠️ TO IMPLEMENT @@ -238,9 +238,9 @@ The implementation is divided into multiple phases (Pull Requests) to facilitate --- -## Phase 2: FCM Service Implementation ⚠️ TO IMPLEMENT +## Phase 2: FCM Service Implementation ✅ COMPLETE -**Branch:** `feature/fcm-service` (to be created from `main` after Phase 1 merge) +**Branch:** `feature/fcm-service` **Objective:** Implement FCM token management and integrate with existing background notification system.