persistFcmToken method

  1. @override
Future<Either<RepositoryFailure, Unit>> persistFcmToken({
  1. required String? fcmToken,
})
override

Persists the FCM token to the backend device record.

Called by NotificationService when it obtains/refreshes the token or clears it due to notifications being disabled. This method only handles backend persistence—NotificationService owns the token lifecycle (fetch/refresh/cache/local state).

When Called

  • Initial token acquisition (first successful read after enabling)
  • Token rotation/refresh (Firebase Messaging token refresh event)
  • Token cleared due to local disablement (user disables notifications in-app while staying logged in)

Important: Logout Path

Do NOT call this method during logout. DeviceService deletes the device doc on logout via unregisterDevice; NotificationService should perform only local cleanup (clear cached token, detach listeners) without triggering a backend write that would race with device doc deletion.

Token Uniqueness

The backend enforces that a token appears on at most one device doc. On token update, it clears the same token from any other device docs (handles edge cases from offline failures or account switching).

Parameters

  • fcmToken: The new FCM token, or null to clear the token.

Returns

  • Right(unit) on success
  • Left(RepositoryFailure) on failure

Example

// Token obtained
await deviceService.persistFcmToken(fcmToken: newToken);

// Token cleared (e.g., user revoked permission while logged in)
await deviceService.persistFcmToken(fcmToken: null);

Implementation

@override
Future<Either<RepositoryFailure, Unit>> persistFcmToken({
  required String? fcmToken,
}) async {
  logv('DeviceService: persistFcmToken called with token: ${fcmToken != null ? '***' : 'null'}');

  try {
    final deviceId = await getDeviceId();

    // Check authentication - if not authenticated, store pending and return
    if (!_isUserAuthenticated()) {
      logd('DeviceService: User not authenticated, storing pending token update');
      await _updatePendingPayload(
        deviceId: deviceId,
        fcmToken: fcmToken ?? '', // Empty string = explicit null
        hasChangedFields: true,
      );
      // Return success since we've stored it for later
      return const Right(unit);
    }

    // E.3(c) direct-persist reconcile (FCM-061): the authenticated path does
    // NOT lazily load the pending payload, so load it and snapshot the pending
    // token BEFORE the callable. Otherwise a stale token persisted to disk but
    // not yet loaded this session (`_pendingPayload == null` in memory) would
    // make `beforeToken` spuriously null, the reconcile would clear nothing,
    // and a later flush would load + re-send the stale on-disk token,
    // regressing the server (the exact BEH-8 case this guard prevents).
    await _ensurePendingPayloadLoaded();
    final beforeToken = _pendingPayload?.fcmToken;

    // FCM-126 observability-only in-flight overlap counter around the
    // authenticated updateToken dispatch (mirrors `_isFlushingPayload`).
    // Surfaces the FCM-063 two-writer race; it must NOT serialize.
    _fcmPersistInFlightCount++;
    if (_fcmPersistInFlightCount > 1) {
      logd('DeviceService: FCM persist in-flight overlap — a second '
          'authenticated updateToken dispatched while $_fcmPersistInFlightCount '
          'were already in flight (FCM-063 observability, not serialized)');
    }
    try {
      final result = await _deviceCallable.call({
        'action': 'updateToken',
        'deviceId': deviceId,
        'fcmToken': fcmToken,
      });

      final data = safeResultData(result);
      if (data['success'] != true) {
        logw('DeviceService: persistFcmToken response indicated failure');
        // Store token update in pending payload
        // Use empty string as sentinel for explicit null
        await _updatePendingPayload(
          deviceId: deviceId,
          fcmToken: fcmToken ?? '', // Empty string = explicit null
          hasChangedFields: true,
        );
        return const Left(RepositoryFailure.unexpected);
      }

      // E.3(c) reconcile (BEH-8; FCM-011, folds FCM-019): clear the pending
      // token entry ONLY when the LIVE pending token is still the exact
      // snapshot captured before the callable — so a genuinely-newer token
      // queued during the await survives, and a stale-but-DIFFERENT pending
      // token is not spuriously kept (a value guard would wrongly assume a
      // differing pending token must be newer). Drop the payload if nothing
      // remains to sync.
      final live = _pendingPayload;
      if (live != null && live.fcmToken == beforeToken) {
        final reconciled = live.withFcmTokenCleared();
        if (reconciled.hasDataToSync) {
          _pendingPayload = reconciled;
          await _savePendingPayload(_pendingPayload);
        } else {
          await _clearPendingPayload();
        }
      }

      logd('DeviceService: FCM token updated successfully');
      return const Right(unit);
    } finally {
      _fcmPersistInFlightCount--;
    }
  } on FirebaseFunctionsException catch (e) {
    loge(e, 'DeviceService: Firebase Functions error during token update');

    // Store token update in pending payload for retry on transient errors
    if (_shouldStorePendingOnError(e)) {
      final deviceId = await getDeviceId();
      await _updatePendingPayload(
        deviceId: deviceId,
        fcmToken: fcmToken ?? '', // Empty string = explicit null
        hasChangedFields: true,
      );
    }

    return _mapFirebaseFunctionsException(e);
  } catch (e) {
    loge(e, 'DeviceService: Unexpected error during token update');

    // Store token update in pending payload for retry
    final deviceId = await getDeviceId();
    await _updatePendingPayload(
      deviceId: deviceId,
      fcmToken: fcmToken ?? '', // Empty string = explicit null
      hasChangedFields: true,
    );

    return const Left(RepositoryFailure.unexpected);
  }
}