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
115 changes: 102 additions & 13 deletions lib/background/background.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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';
import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart' as notification_service;
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';
Expand All @@ -22,34 +25,119 @@ Future<void> serviceMain(ServiceInstance service) async {

final Map<String, Map<String, dynamic>> activeSubscriptions = {};
final nostrService = NostrService();
final db = await openMostroDatabase('events.db');
final eventStore = EventStorage(db: db);
EventStorage? eventStore;

bool initialized = false;
Completer<void>? 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<void>();

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<dynamic>;
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;

Expand Down Expand Up @@ -81,7 +169,8 @@ Future<void> 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);
Expand Down
3 changes: 2 additions & 1 deletion lib/data/models/enums/storage_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
117 changes: 93 additions & 24 deletions lib/services/fcm_service.dart
Original file line number Diff line number Diff line change
@@ -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<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
try {
Expand Down Expand Up @@ -45,16 +52,93 @@ Future<void> 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<void>();
final handlersSub = service.on('handlers-registered').listen((_) {
if (!handlersReady.isCompleted) handlersReady.complete();
});

// 2. Listen for service-ready (emitted after NostrService.init()).
final serviceReady = Completer<void>();
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<String, dynamic>? settings;
try {
settings = jsonDecode(settingsJson) as Map<String, dynamic>?;
} 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');
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -219,18 +300,6 @@ class FCMService {
);
}

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
Expand Down
Loading
Loading