initializeFcmToken method

Future<FcmTokenInitResult> initializeFcmToken({
  1. required Future<void> onTokenChanged(
    1. String? newToken,
    2. String? oldToken
    ),
  2. bool isReplay = false,
  3. bool bypassCaptureBackoff = false,
})

Initializes FCM token management and syncs with server.

Call this after user is authenticated. The service will:

  1. Get the current FCM token
  2. Sync it to the server via onTokenChanged
  3. Listen for token refreshes and sync automatically

Returns a FcmTokenInitResult describing the outcome (this replaced the former Future<void> return as of 0.12.0):

  • FcmTokenInitResult.success: a token was captured and either persisted or already current, and the synced latch is set.
  • FcmTokenInitResult.captureFailed: permission was granted but capture or persist failed (null getToken(), a thrown/non-transient persist error); the synced latch is NOT set and the next trigger retries.
  • FcmTokenInitResult.skippedQuietly: capture was intentionally not attempted (capture backoff active, a logout in progress, or web FCM enabled without a configured FCM_WEB_VAPID_KEY).

onTokenChanged is called when the token changes. Use this to sync the token to your backend server. The callback receives:

  • newToken: The new FCM token (null if unregistering)
  • oldToken: The previous FCM token (null if first registration)

The callback MUST rethrow on a persistence failure (as the shipped default callbacks do) — a throw signals "not synced" so the latch stays unset and the next trigger retries; swallowing it re-introduces the mark-synced-on- failure bug.

Example:

final result = await notificationService.initializeFcmToken(
  onTokenChanged: (newToken, oldToken) async {
    // Rethrow on failure so a failed persist is retried, not marked synced.
    await myBackendService.updateFcmToken(newToken, oldToken);
  },
);
if (result == FcmTokenInitResult.captureFailed) {
  // Permission granted but the token was not synced — will retry later.
}

bypassCaptureBackoff (A.6/FCM-039): when true, skips the persisted capture backoff (see _isFcmCaptureBackedOff) so an explicit user/settings trigger always re-attempts. The login/silent path passes false and relies on the logout backoff reset — bypassing there would defeat the throttle.

Implementation

Future<FcmTokenInitResult> initializeFcmToken({
  required Future<void> Function(String? newToken, String? oldToken) onTokenChanged,
  bool isReplay = false,
  bool bypassCaptureBackoff = false,
}) async {
  if (_testInitializeFcmTokenOverride != null) {
    return _testInitializeFcmTokenOverride!();
  }

  // FCM-002: register the onTokenRefresh listener ONCE, idempotently and up
  // front, so (a) re-entry after a failed/null attempt never stacks listeners
  // and (b) an in-session rotation is captured even when the initial sync
  // failed. The listener stays live regardless of the capture outcome below.
  _tokenRefreshSubscription ??= (_testOnTokenRefreshStreamOverride ??
          FirebaseMessaging.instance.onTokenRefresh)
      .listen(_handleTokenRefresh);

  // A COMPLETED init still no-ops here via the retained latch. FCM-038:
  // redundant calls are coalesced by BOTH this early-return (for a completed
  // init) AND the ENTRY single-flight primitive below (for an in-flight one) —
  // credit both; _hasFcmTokenInitialized is NOT inert.
  if (_hasFcmTokenInitialized) {
    logd('FCM token already initialized');
    return FcmTokenInitResult.success;
  }

  // FCM-038 ENTRY single-flight primitive — coalesce concurrent callers onto
  // the in-flight invocation and hand them its result. do not remove — see
  // FCM-038 (this is NOT redundant with _hasFcmTokenInitialized, which is
  // deliberately left false on a null-getToken/persist failure so a
  // dispose-only reset would make this a run-once latch that kills retry).
  final existing = _fcmInitInFlight;
  if (existing != null) {
    logd('FCM token init already in flight — coalescing redundant call');
    return existing.future;
  }
  final inFlight = Completer<FcmTokenInitResult>();
  _fcmInitInFlight = inFlight;

  // D1/RC-05: clear any stale in-flight rotation stash from a prior generation
  // now that we are the fresh window-opener — placed AFTER the single-flight
  // coalesce check above so a concurrent coalescing caller can never reset the
  // deferral flag or discard a stash mid-capture (FCM-020/BEH-20). Still runs
  // BEFORE _captureInitialFcmToken opens the capture window, so a fresh
  // capture's finally-replay can never replay a stale refreshed token over the
  // current one (a hazard an epoch guard cannot catch — the replayer IS the
  // current generation consuming stale DATA).
  _stashedRefreshToken = null;
  _isCapturingInitialToken = false;

  _onTokenChanged = onTokenChanged;

  var result = FcmTokenInitResult.captureFailed;
  try {
    result = await _captureInitialFcmToken(
      isReplay: isReplay,
      bypassCaptureBackoff: bypassCaptureBackoff,
    );
  } finally {
    // Release the single-flight primitive on BOTH success and failure. Only
    // clear it if it is still OURS (a dispose→reinit may have replaced it),
    // and unblock any coalesced awaiter that dispose did not already complete.
    if (identical(_fcmInitInFlight, inFlight)) {
      _fcmInitInFlight = null;
    }
    if (!inFlight.isCompleted) {
      inFlight.complete(result);
    }
  }
  return result;
}