Flutter iOS SDK Setup

Reteno Flutter iOS SDK Setup

The plugin and Reteno 2.7.3 podspecs declare iOS 12.0 as the technical minimum, with iOS 14.0 or later recommended and Xcode 15.0+ and Swift 5.7+ required.

This guide uses unpinned FlutterFire, which raises those minimums. As of August 2026, FlutterFire resolves Firebase Apple SDK 12.x, which requires iOS 15.0, Dart 3.6.0, Flutter 3.27.0, Xcode 26.2 or later, and CocoaPods 1.12.0 or later. See the FlutterFire version table and the Firebase Apple release notes. Use the highest minimum your resolved dependencies require, and give Runner and every notification extension the same target.

1. Install the plugin and configure Firebase

flutter pub add reteno_plugin:1.11.0
flutter pub add firebase_core

Commit pubspec.lock so the resolved FlutterFire versions stay reproducible.

The plugin and the extensions below use CocoaPods. On Flutter 3.44 or later, where Swift Package Manager is on by default, keep the project on one dependency manager:

flutter:
  config:
    enable-swift-package-manager: false

Add an iOS app with the exact Runner bundle identifier to Firebase, run flutterfire configure, and keep the generated configuration in the project. See Firebase setup for Flutter.

The plugin links FirebaseMessaging, but token ownership depends on the mode: automatic reports APNs only, manual observes the FCM token without replacing the application's MessagingDelegate, and external leaves Firebase entirely to the application. Firebase must be initialized before Reteno in manual and external; an APNs-only application can skip the Dart Firebase initialization.

2. Add a Notification Service Extension

NotificationServiceExtension enables rich notifications and delivered-status reporting. Taps and actions are handled separately by the notification delegate coordinator; keep both configured.

  1. In Xcode, select File → New → Target, then Notification Service Extension.

  1. Name the target NotificationServiceExtension and finish creating it. Select Cancel in the scheme-activation prompt so Xcode keeps running Runner.

  1. Set the extension deployment target to the same value as Runner — iOS 15.0 for the current FlutterFire flow. The screenshot below shows the bundled example's iOS 14.0 target.

  1. Replace NotificationService.swift with:
import UserNotifications
import Reteno

class NotificationService: RetenoNotificationServiceExtension {}

The unresolved Reteno import is expected until the extension pod is installed in the next step. See Apple's Notification Service Extension documentation.

3. Add Reteno to the extension target

The Flutter plugin already pins Reteno 2.7.3 for Runner, so do not repeat it there. Keep the Flutter-generated Podfile structure, set the platform, and add the same version to each notification extension:

platform :ios, '15.0'

target 'Runner' do
  use_frameworks!
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

target 'NotificationServiceExtension' do
  use_frameworks!
  pod 'Reteno', '2.7.3'
end

Add the same pod to NotificationContentExtension if the application has one. If post_install hard-codes IPHONEOS_DEPLOYMENT_TARGET, update it to the effective application target.

Then install the pods and open the generated workspace:

flutter pub get
cd ios
pod install
open Runner.xcworkspace

Keep Podfile.lock under version control so Runner and every extension resolve the same native SDK.

When upgrading, an older Podfile.lock can pin an incompatible Firebase line and make pod install report a conflict. Update the FlutterFire constraints first, then run a targeted pod update Firebase/CoreOnly Firebase/Messaging FirebaseCore FirebaseMessaging from ios and commit the resulting lockfile.

4. Configure App Groups

Add the same App Group capability to both Runner and NotificationServiceExtension:

group.<main-app-bundle-id>.reteno-local-storage

Use the main application bundle identifier in both targets; do not include the extension suffix.

  1. Open Signing & Capabilities for Runner and select + Capability.

  1. Select App Groups and add the group shown above.

  1. Open Signing & Capabilities for NotificationServiceExtension and enable the exact same group.

Both provisioning profiles must contain the App Group entitlement. If you correct the group after the SDK has already run, delete and reinstall the app before retesting shared storage.

For more information, see Configuring App Groups.

5. Enable push and connect the provider

Add the Push Notifications capability to the Runner target. Do not add it to NotificationServiceExtension.

Then connect the mobile app in Reteno → Settings → Mob Push:

  • For direct APNs, upload the .p8 key or .p12 certificate to Reteno with a Topic that matches the Runner bundle identifier.
  • For FCM, add the APNs authentication key to the Firebase iOS app, upload the Firebase service-account JSON to Reteno, and enable Background Modes → Background fetch and Remote notifications for Runner.

Leave FirebaseAppDelegateProxyEnabled enabled; the FCM flow described here relies on Firebase method swizzling.

6. Initialize Firebase, then Reteno

Initialize both SDKs once during application startup:

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'package:reteno_plugin/reteno.dart';

import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Broadcast streams do not replay. Install application-lifetime listeners
  // before Reteno initialization can flush a queued cold-start event.
  Reteno.onUserNotificationAction.listen((action) {
    // Route the notification action.
  });
  Reteno.onRetenoNotificationClicked.listen((payload) {
    // Route the notification click.
  });

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  await Reteno().initialize(
    accessKey: '<your_access_key>',
    options: RetenoInitOptions(
      lifecycleTrackingOptions: LifecycleTrackingOptions.all(),
      deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.automatic,
    ),
  );

  runApp(const MyApp());
}

Register every Reteno push and action stream synchronously, before awaiting initialize(). Events queued during cold launch are flushed while initialization completes, and these broadcast streams do not replay them. getInitialNotification() recovers a launch payload for a plain tap, but not the action stream's actionId, custom data, and link.

Choose the final token mode before this call: the example uses automatic, manual gives the plugin FCM ownership, and external uses the alternative in section 7. From version 1.11.0, a second initialize() with a different mode fails, because native token ownership is fixed by the first call.

From version 1.11.0, customDeviceId is also supported on iOS. The provider is awaited once before native initialization, and null selects Reteno's default provider without erasing an ID stored by an earlier custom-ID integration:

await Reteno().initialize(
  accessKey: '<your_access_key>',
  customDeviceId: () async => await loadStableDeviceId(),
  options: const RetenoInitOptions(
    deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.automatic,
  ),
);

Return one stable, non-empty value across launches. A blank value throws ArgumentError, a non-blank value is forwarded verbatim, and there is no timeout, so a provider that never completes leaves initialize() pending. Enabling this option on an existing installation replaces the stored default ID and changes that device's identity in Reteno; test the migration against a non-production mobile app first.

Do not call Reteno.start(...), register a native Reteno MessagingDelegate, or pass a token to the native Reteno SDK from AppDelegate. The Flutter plugin owns initialization and token forwarding; in external mode, synchronize the token only through the Dart setPushToken() API.

7. Select the iOS push token mode

Choose the mode that matches the notification service configured for the mobile app in Reteno:

Reteno mobile app configurationToken modeToken owner
APNs for iOS, including FCM for Android + APNs for iOSRetenoDeviceTokenHandlingMode.automaticThe bridge reports the APNs token
FCM-only; the plugin manages the tokenRetenoDeviceTokenHandlingMode.manualThe plugin reports the current and refreshed FCM token
FCM-only; the application owns Firebase token handlingRetenoDeviceTokenHandlingMode.externalThe application reports the token with setPushToken()

Never send both an APNs token and an FCM token for the same integration.

Token routing guarantees in 1.11.0

The bridge assumes no mode until Dart selects one, then forwards only a matching source: the APNs token in automatic, the plugin-observed FCM token in manual, and the setPushToken() value in external. An early APNs callback can therefore never be flushed as an FCM token. In manual, the raw APNs token is retained only so Firebase can build its APNs-to-FCM mapping, and an explicit setPushToken() is accepted for backward compatibility only. In external, the bridge neither forwards APNs nor configures Firebase.

The router deduplicates an unchanged token, and repeated initialization with the same mode is idempotent. In manual, the plugin observes Firebase refresh notifications without assigning Messaging.messaging().delegate, so existing FlutterFire and application callbacks keep their owner.

Notification delegate coordination

Notification callbacks are independent of the token mode. During plugin registration the bridge installs one UNUserNotificationCenterDelegate coordinator, which standard FlutterFire initialization preserves in its forwarding chain. A Reteno payload reaches native Reteno processing and every forwarding delegate; a non-Reteno payload reaches only the downstream delegate.

The supported topology leaves UNUserNotificationCenter.current().delegate unset until the generated plugins register. Do not assign a FlutterAppDelegate or other FlutterAppLifeCycleProvider as the notification-center delegate before GeneratedPluginRegistrant runs: the coordinator can wrap that provider while FlutterFire wraps the coordinator, producing recursive or duplicate callbacks. A delegate installed after registration must forward its callbacks to the previous one.

Foreground presentation follows the actual delegate order, so configure it on the app's own delegate and verify display on a physical device. Completion handlers are guarded, so the system completion runs once even if a downstream delegate calls it twice.

For add-to-app or a lazily created Flutter engine, register the Reteno plugin before application(_:didFinishLaunchingWithOptions:) returns. A notification response delivered earlier cannot be recovered later.

External FCM token handling

Install the Dart Firebase Messaging package:

flutter pub add firebase_messaging

The helper below replaces the Reteno initialization in section 6 for external mode; keep the Firebase initialization shown there. It gates both the initial and refreshed FCM token on APNs readiness:

import 'dart:async';

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:reteno_plugin/reteno.dart';

class RetenoExternalFcmTokenSync {
  static const _apnsAttempts = 20;
  static const _apnsInterval = Duration(milliseconds: 500);

  final Reteno _reteno = Reteno();
  StreamSubscription<String>? _subscription;

  Future<void> start() async {
    await _reteno.initialize(
      accessKey: '<your_access_key>',
      options: const RetenoInitOptions(
        deviceTokenHandlingMode: RetenoDeviceTokenHandlingMode.external,
      ),
    );

    _subscription = FirebaseMessaging.instance.onTokenRefresh.listen(sync);
    await sync();
  }

  /// Call again after identifying or changing the Reteno user.
  Future<void> sync([String? refreshedToken]) async {
    if (!await _waitForApnsToken()) return;

    final token = refreshedToken ?? await FirebaseMessaging.instance.getToken();
    if (token == null || token.isEmpty) return;

    await _reteno.setPushToken(token);
  }

  Future<bool> requestPermissionAndSync({bool provisional = false}) async {
    final granted =
        await _reteno.requestPushPermission(provisional: provisional);
    if (granted) await sync();
    return granted;
  }

  Future<bool> _waitForApnsToken() async {
    for (var attempt = 0; attempt < _apnsAttempts; attempt++) {
      if (await FirebaseMessaging.instance.getAPNSToken() != null) return true;
      await Future<void>.delayed(_apnsInterval);
    }
    return false;
  }

  Future<void> dispose() async {
    await _subscription?.cancel();
    _subscription = null;
  }
}

Start it after Firebase initialization:

final tokenSync = RetenoExternalFcmTokenSync();
await tokenSync.start();

Keep one instance for the application lifetime and dispose it with the owning service. Each attempt waits up to about ten seconds for APNs, because Firebase cannot issue an FCM token before APNs registration completes; retry on resume if it was still unavailable. Call sync() again after identifying or changing the Reteno user. Pass the FCM registration token, never the APNs token: the native router forwards whatever setPushToken() receives without checking its type.

8. Request permission and verify setup

Request permission at the appropriate point in your user flow. Pass provisional: true to request provisional iOS authorization:

final granted = await Reteno().requestPushPermission(provisional: true);
final issues = await Reteno().diagnose();
if (!granted || issues.isNotEmpty) {
  // Keep the user in the setup flow and inspect the reported issues.
}

In external mode, use the helper instead so permission and the first FCM synchronization stay in one sequence:

final granted = await tokenSync.requestPermissionAndSync(provisional: true);
final issues = await Reteno().diagnose();

APNs registration is asynchronous, so a diagnostic call made right after the prompt can temporarily return REMOTE_NOTIFICATIONS_NOT_REGISTERED.

On iOS, diagnostics report native readiness, the authorization state, and remote-notification registration. An empty list does not validate the access key, Firebase or APNs credentials, App Groups, the service extension, delegate wiring, or external token synchronization.

Finish with an end-to-end test on a physical device:

  1. Identify the test contact with setUserAttributes, and in external mode synchronize the token again afterwards.
  2. Confirm diagnose() reports no persistent issues.
  3. Send a test notification from Reteno, not only from Firebase.
  4. Verify delivery and a rich image handled by NotificationServiceExtension.
  5. Verify the Reteno foreground, tap, and action streams along with any FlutterFire callbacks the app expects.

For direct APNs, the build signing and the Reteno APNs environment must match, because sandbox and production device tokens are not interchangeable. Reteno uses the production endpoint by default; for an Xcode build, contact Reteno Support to enable sandbox = true on the development mobile app. See Testing APNs delivery.

Upgrading from the previous guide

Keep the Notification Service Extension, App Group, Push Notifications capability, and provider credentials. Remove legacy code that:

  • calls Reteno.start(...) from AppDelegate;
  • forwards APNs or FCM tokens directly to native Reteno;
  • registers Reteno-only notification or MessagingDelegate workarounds;
  • forwards callbacks to Reteno.userNotificationService from a notification-center multiplexer, which would make Reteno process each notification twice;
  • assigns FlutterAppDelegate/FlutterAppLifeCycleProvider as the notification-center delegate before GeneratedPluginRegistrant.

Keep application-specific delegate code that serves other behavior, and make sure a delegate installed later keeps forwarding instead of replacing the chain.

If an earlier iOS build passed customDeviceId despite the old Android-only guidance, review that value first: 1.11.0 now calls the provider and can replace the stored default device ID.

If the migration also changes FlutterFire versions, use the pod-update procedure in section 3 and commit pubspec.lock and Podfile.lock together.

Optional: image carousel push UI

To display the Reteno image carousel, add a NotificationContentExtension, install Reteno 2.7.3 in that target, and follow the iOS Images Carousel guide. That guide configures the ImageCarousel category only and is not a complete GIF setup. Keep this guide's deployment target and Reteno 2.7.3 pin rather than the ones it declares.

NotificationContentExtension is optional. The standard NotificationServiceExtension and shared App Group remain required.