initializeFcmToken method
Initializes FCM token management and syncs with server.
Call this after user is authenticated. The service will:
- Get the current FCM token
- Sync it to the server via
onTokenChanged - 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;
}