initialize method

Future<void> initialize({
  1. NotificationActionCallback? onNotificationTapped,
  2. NotificationButtonActionCallback? onNotificationAction,
  3. ForegroundMessageCallback? onForegroundMessage,
  4. NotificationErrorCallback? onError,
  5. bool showNotificationsInForeground = true,
  6. int reminderIntervalDays = 30,
  7. Future<void> onTokenChanged(
    1. String?,
    2. String?
    )?,
  8. NotificationSettingsDeepLinkCallback? onSystemNotificationSettingsOpened,
  9. AuthServiceInt? authService,
  10. @Deprecated('Use authService parameter instead for race-free initialization') bool autoConnectAuth = true,
})

Initializes the notification service.

This method sets up all notification handling including:

  • Local notification plugin initialization
  • FCM message listeners (foreground, background, terminated)
  • Notification action handlers
  • Platform-specific configuration
  • Auto-wiring to auth service (if registered in GetIt)

This is the only method consuming apps need to call to set up notifications.

Parameters:

  • onNotificationTapped: Callback for when user taps a notification
  • onNotificationAction: Callback for when user taps an action button
  • onForegroundMessage: Callback for when notification arrives in foreground
  • onError: Callback for handling errors
  • showNotificationsInForeground: Whether to display notifications in foreground (default: true)
  • reminderIntervalDays: Days between permission reminders (default: 30)
  • onTokenChanged: Callback for FCM token changes. If not provided and auth service is available, uses the default Firebase callable function implementation.
  • authService: Pass auth service directly for race-free initialization. When provided, sets up auth connection immediately without using GetIt. This is the recommended approach for new code (callbacks are wired before the Firebase auth listeners attach, so no auth event is missed).
  • autoConnectAuth: Whether to automatically connect to auth service if available in GetIt (default: true). Set to false if you want to call connectToAuthService manually with custom configuration. Deprecated: Use authService parameter instead.

For race-free initialization, pass authService directly and set autoConnectAuth: false:

await notificationService.initialize(
  authService: auth,
  autoConnectAuth: false,
  onNotificationTapped: (route, data) async { ... },
);

Legacy Initialization

await NotificationService().initialize(
  onNotificationTapped: (route, data) async {
    if (route != null) {
      Navigator.of(context).pushNamed(route, arguments: data);
    }
  },
  showNotificationsInForeground: true,
);

Implementation

Future<void> initialize({
  NotificationActionCallback? onNotificationTapped,
  NotificationButtonActionCallback? onNotificationAction,
  ForegroundMessageCallback? onForegroundMessage,
  NotificationErrorCallback? onError,
  bool showNotificationsInForeground = true,
  int reminderIntervalDays = 30,
  Future<void> Function(String? newToken, String? oldToken)? onTokenChanged,
  NotificationSettingsDeepLinkCallback? onSystemNotificationSettingsOpened,
  // NEW: Pass auth service directly for race-free initialization
  AuthServiceInt? authService,
  // DEPRECATED: Use authService parameter instead
  @Deprecated('Use authService parameter instead for race-free initialization')
  bool autoConnectAuth = true,
}) async {
  // CRITICAL: Set auth reference FIRST, before any await statements.
  //
  // The auth callback (handleAuthenticated) may fire immediately after
  // AuthService construction via a microtask. If we await anything before
  // setting _authService, the callback could execute while _authService
  // is still null.
  //
  // Critical constraint: set _authService synchronously, before any await.
  if (authService != null) {
    _authService = authService;
    _isConnectedToAuthService = true;
    logd('NotificationService: Initialized with auth service (race-free path)');
  }

  // Re-init recovery (Issue 56): this is a factory-singleton reused across
  // app-init gate retries; a prior [dispose] (on the dispose-on-failure path)
  // closes [_badgeCountController]. Recreate it before re-running setup so
  // [badgeCountStream] is live post-recovery and a later [updateBadgeCount]
  // `add()` does not throw on the closed controller.
  _recreateBadgeControllerIfClosed();

  if (_initialized) {
    logi('NotificationService already initialized');
    return;
  }

  try {
    _onNotificationTapped = onNotificationTapped;
    _onNotificationAction = onNotificationAction;
    _onForegroundMessage = onForegroundMessage;
    _onError = onError;
    _showNotificationsInForeground = showNotificationsInForeground;
    _reminderIntervalDays = reminderIntervalDays;
    _onSystemNotificationSettingsOpened = onSystemNotificationSettingsOpened;

    // B.1 (BUG 3): assign the token-changed callback SYNCHRONOUSLY here — before
    // any await below — so its value is FINAL before an initial auth event can
    // reach _handleLogin's null check, closing the cold-start capture race.
    // Placed after the `if (_initialized) return` early-return so a redundant
    // initialize() does not overwrite an already-wired callback (used by both
    // the race-free and legacy paths).
    if (onTokenChanged != null) {
      _onTokenChanged = onTokenChanged;
    }

    // Initialize local notifications plugin
    await _initializeLocalNotifications();

    // Set up FCM message handlers
    await _setupFCMHandlers();

    // Check for initial message (app opened from terminated state via notification)
    await _checkInitialMessage();

    _initialized = true;
    logi('NotificationService initialized successfully');

    // Token callback already stored synchronously in the prefix above (B.1).

    // Set up notification settings deep link channel
    if (onSystemNotificationSettingsOpened != null) {
      _settingsChannel =
          const MethodChannel(notificationSettingsChannelName);
      _settingsChannel!.setMethodCallHandler(_handleSettingsMethodCall);
      logi('Notification settings deep link channel initialized');

      // Check for pending cold-launch intent.
      // On cold launch, native code stores the settings intent/delegate call
      // because the Dart handler isn't registered yet when the engine starts.
      // This pull-based check retrieves any pending event.
      try {
        final pending = await _settingsChannel!
            .invokeMethod<Map<dynamic, dynamic>?>('getPendingSettingsIntent');
        if (pending != null) {
          final channelId = pending['channelId'] as String?;
          logi('Found pending notification settings deep link from cold launch');
          // Use addPostFrameCallback to ensure the widget tree (including the
          // Navigator/Router) is built before the consuming app's callback
          // navigates.
          //
          // TIMING NOTE: addPostFrameCallback guarantees the widget tree is
          // mounted, but NOT that the consuming app's async initialization is
          // complete (auth state, router configuration, initial data fetch,
          // etc.). This is the same constraint as onNotificationTapped
          // cold-launch handling. If the consuming app's callback needs to
          // navigate after its own async setup, it should queue the navigation
          // (e.g., store the info and process it when the home screen mounts).
          WidgetsBinding.instance.addPostFrameCallback((_) {
            _handleSettingsDeepLink(channelId);
          });
        }
      } on MissingPluginException {
        // Native side hasn't set up the handler — feature not configured.
        // This is expected when the consuming app hasn't added the native setup.
        logd('No native handler for getPendingSettingsIntent — cold launch '
            'deep links will not be detected');
      }
    }

    // Skip auto-connect if authService was provided directly (race-free path)
    if (authService != null) {
      logd('NotificationService: Auth service provided directly, skipping auto-connect');
      // Token callback already stored above, auth service already set at top
    } else if (autoConnectAuth) {
      // Legacy path: Auto-wire to auth service if available in GetIt
      // Check if auth service is available before attempting to connect
      bool authAvailable = false;
      try {
        authAvailable = GetIt.I.isRegistered<AuthServiceInt>();
      } catch (e) {
        // GetIt not initialized or other error
        logd('GetIt check failed: $e');
      }

      if (authAvailable) {
        await connectToAuthService(onTokenChanged: onTokenChanged);
      } else {
        // This is a configuration error - report it but don't crash
        const errorMsg = 'autoConnectAuth is enabled but AuthServiceInt is not '
            'registered in GetIt. FCM token management will not work automatically. '
            'Either register AuthServiceInt before initializing NotificationService, '
            'or set autoConnectAuth: false and call connectToAuthService() manually later.';
        loge(errorMsg);
        _onError?.call(errorMsg, null);
      }
    }
    // Note: If neither authService nor autoConnectAuth, token callback is still
    // stored above for manual connection later via connectToAuthService()

    // B.3 (BUG 3/BEH-4/FCM-033): replay a login event that fired (and deferred
    // via B.2) during initialize()'s platform-channel awaits — now that
    // _initialized is true and the FCM handlers are set up. FIRE-AND-FORGET
    // (unawaited) so DreamicServices.initialize's Future.wait — which apps
    // commonly gate first-frame/splash on — never blocks on the deferred
    // capture's ~7.5s _waitForApnsToken + persist. Capture proceeds
    // asynchronously (BEH-4: "runs as soon as init completes", not "blocks
    // init"). Proceed only if a user is still authenticated at replay time and
    // pass isReplay: true so the replay does NOT reset the FCM-044 logout-window
    // guard (FCM-121) — a logout landing mid-replay must still no-op the
    // capture. The distinct deferred-capture Crashlytics signal
    // (_FcmDeferredCaptureFailure) is emitted from initializeFcmToken's inner
    // catch keyed on isReplay (F10); this .catchError is belt-and-suspenders for
    // a rare escaping platform-channel throw only (which, unawaited, would
    // otherwise surface as an unhandled async error).
    unawaited(_replayDeferredLoginCaptureIfPending().catchError(
        (e) => loge(e, 'Deferred FCM login capture failed')));
  } catch (e, stackTrace) {
    loge(e, 'Failed to initialize NotificationService', stackTrace);
    _onError?.call(e, stackTrace);
    rethrow;
  }
}