Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/FCM_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -37,6 +40,9 @@ Future<void> 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),
Expand Down Expand Up @@ -82,3 +88,22 @@ void _initializeTimeAgoLocalization() {

// English is already the default, no need to set it
}

/// Initialize Firebase Cloud Messaging
Future<void> _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');
}
}
273 changes: 273 additions & 0 deletions lib/services/fcm_service.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<String>? _tokenRefreshSubscription;
StreamSubscription<RemoteMessage>? _foregroundMessageSubscription;

bool _isInitialized = false;

FCMService(this._prefs);

bool get isInitialized => _isInitialized;

Future<void> 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<bool> _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<String?> _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<void> _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<String?> 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<void> 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;
}
}
8 changes: 8 additions & 0 deletions lib/shared/providers/fcm_service_provider.dart
Original file line number Diff line number Diff line change
@@ -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<FCMService>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return FCMService(prefs);
});
1 change: 1 addition & 0 deletions lib/shared/providers/providers.dart
Original file line number Diff line number Diff line change
@@ -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';