Feature SDKs
How to add push notifications to a Flutter app with FCM
Free Reference for push_notification_sdk: every variable, option and package it generates. Command: archipelago generate push_notification_sdk
Generate the Push Notification SDK, configure Firebase, request permission, read the device token, and handle tap navigation from a notification.
What you'll learn
- Generating the Push Notification SDK with the FCM provider
- Adding OneSignal with its separate Pro brick
- Configuring Firebase on Android and iOS
- Requesting permission and retrieving the device token
- Handling tap-driven navigation from a notification
- Creating an Android notification channel and targeting the tap trampoline
- Tiering messages, setting quiet hours and showing lock-screen-safe text
Prerequisites
- An existing Archipelago monorepo (see Monorepo Scaffolding)
- A Firebase project for FCM
- A OneSignal account and a Pro subscription, only if you add OneSignal
Step 1: Generate the Push Notification SDK
archipelago generate push_notification_sdkThe brick has one variable, includeFcm. It must be true. Generation stops if you set it to false, because this brick always includes FCM. OneSignal is a separate brick (see Step 5).
{
"includeFcm": true
}archipelago generate push_notification_sdk --config push_notification_config.jsonStep 2: Understand the Generated Structure
features/push_notification/
├── push_notification_api/ # PushNotificationSDK, models, policy port
├── push_notification_impl/ # Orchestrator, policy and its store
└── push_notification_fcm_impl/ # FCM handlerYour app code and other features depend only on push_notification_api.
The post-generation hook registers the FCM module first, then PushNotificationGlobalModule, in lib/di/injector.dart. The order matters. The global module reads every provider handler when it is built. It stays an eager SDK, because building it attaches the message and tap listeners at boot, before the first push arrives.
Step 3: Configure FCM on Android
Run
flutterfire configure, or downloadgoogle-services.jsonfrom the Firebase console and place it at:apps/<appName>/android/app/google-services.jsonCreate a default notification channel (required on Android 8 and later). The brick does not generate it:
kotlin// apps/<appName>/android/app/src/main/kotlin/.../MainActivity.kt override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( "default", "General", NotificationManager.IMPORTANCE_DEFAULT, ) getSystemService(NotificationManager::class.java) .createNotificationChannel(channel) } }Target the tap trampoline. The brick writes
NotificationActivity.ktnext toMainActivity.ktand declares it inAndroidManifest.xml, so a cold-start tap does not run heavy work inMainActivity.onCreate. Put"click_action": "<your.package.name>.NOTIFICATION_TAP"in the FCM data payload, where the package name ispackage_nameinarchipelago.yaml.MainActivityfrom the generated project already forwardsACTION_VIEWintents to the deeplink channels.
Step 4: Configure FCM on iOS
Download
GoogleService-Info.plistfrom the Firebase console and place it at:apps/<appName>/ios/Runner/GoogleService-Info.plistAdd no
FirebaseApp.configure()call.bootstrap.dartstarts Firebase from Dart, in a guarded call beforeinitializeInjector(). Without Firebase config, the app still boots and this vendor is skipped.In Xcode, enable these capabilities for the
Runnertarget:- Push Notifications
- Background Modes → Remote notifications
Step 5: Add OneSignal (Optional, Pro)
OneSignal is a separate brick. It adds a second provider next to FCM. Generate push_notification_sdk first. The OneSignal brick stops with an error if it is missing.
archipelago generate push_notification_onesignalThe brick has one prompt, oneSignalAppId. Copy it from your OneSignal dashboard under Settings, then Keys and IDs. It is not a secret. The handler calls OneSignal.initialize itself, so you add nothing to main.dart. If you left the ID empty, set it in onesignal_push_notification_handler.dart.
The hook patches the di_modules list so every handler registers before the orchestrator. It touches no AndroidManifest.xml, Info.plist or Gradle file. Follow the OneSignal Flutter setup guide for the Firebase key (Android) and the APNs key (iOS). Enable the same Xcode capabilities as for FCM.
OneSignal stays dormant until the user grants marketing consent.
Step 6: Request Permission and Get the Token
In a post-launch initializer or your onboarding flow. Take the SDK by constructor:
// Request system permission (shows the OS prompt on first call)
final permission = await pushSDK.requestPermission();
// Retrieve the device token for your backend
final token = await pushSDK.getToken(); // PushToken?, or null if it fails
if (token != null) {
await userRepository.registerDeviceToken(token);
}Step 7: Handle Tap-Driven Navigation
When a user taps a notification, the SDK emits on tapStream. Listen once, after the router is ready. A post-launch initializer is a good place, because it receives the router:
pushSDK.tapStream.listen((notification) {
if (notification.route != null) {
router.push(RouteUri.parse(notification.route!));
}
});The notification.route value comes from a route key in the data payload. Your backend controls where deep links land.
Step 8: Subscribe to Topics
FCM has native topics. OneSignal has none, so its handler simulates topics with tags:
// Subscribe the user to the "premium" topic
await pushSDK.subscribeToTopic('premium');
// OneSignal: adds the tag topic_premium = true
// Unsubscribe
await pushSDK.unsubscribeFromTopic('premium');
// OneSignal: removes the tag topic_premiumWith OneSignal, segment your audiences by the topic_* tag keys.
Step 9: Tier Messages, Quiet Hours and the Lock Screen
Every foreground message passes the notification policy before it is shown (full rules and flowchart: push_notification_sdk).
1. Tag each message on the server with a tier in the data payload:
{
"message": {
"token": "<FCM_TOKEN>",
"notification": { "title": "Weekend sale", "body": "20% off this weekend" },
"data": { "tier": "marketing", "route": "/offers" }
}
}Use critical for security alerts, transactional for receipts and codes, and marketing for promotions. Marketing needs marketing consent, respects quiet hours and is capped at 3 per 24 hours. A message without a tier behaves as before the policy: shown unchanged, never capped, never redacted. To redact a message of any tier, add "sensitive": "true".
2. Let the user set quiet hours. The brick ships a settings page at /notification-settings with quiet hours and a Promotions switch (the marketing opt-out). Home's Settings tile opens it. To change the values from your own code:
await getIt<PushNotificationSDK>().setQuietHours(
const QuietHours(startMinute: 22 * 60, endMinute: 7 * 60), // 22:00-07:00
);Marketing that arrives in quiet hours is dropped. To deliver it when quiet hours end instead, add the deferral display:
archipelago generate local_notification_display3. Display sensitive messages with their public text. The local_notification_display brick above does this step for you. Without it, messages tagged critical or transactional, or sent with "sensitive": "true", arrive on foregroundStream with publicTitle / publicBody set. Show them with your local display and use the public text as the lock-screen version:
getIt<PushNotificationSDK>().foregroundStream.listen((n) {
showLocalNotification(
title: n.title,
body: n.body,
lockScreenTitle: n.publicTitle ?? n.title, // Android publicVersion
lockScreenBody: n.publicBody ?? n.body,
);
});If your backend sends a sensitive message with the full text in data['detail_title'] / data['detail_body'] and lock-screen-safe text as the visible title and body, n.title / n.body are the full text and n.publicTitle / n.publicBody are your safe text, so the same code works.
showLocalNotification is your own wrapper (for example around flutter_local_notifications). Messages that arrive while the app is in the background are shown by the OS directly, so keep amounts and personal data out of the title and body you send. The policy does not run for taps.
Common Customizations
| Goal | What to do |
|---|---|
| Multiple notification channels (Android) | Create more NotificationChannel instances with distinct IDs |
| Handle foreground notifications | Listen to foregroundStream on PushNotificationSDK (policy-approved messages only) |
| Change caps or lock-screen text | Install your own DefaultNotificationPolicy(caps: ..., lockScreenTitle: ...) via NotificationPolicyRegistry.instance.install after the push module runs |
| Send token to backend on refresh | Listen to tokenRefreshStream and re-register |
| Apply the policy on your backend too | Send notificationPreferences().toJson() (time zone, quiet hours, marketing consent) with the token; see the backend guide |
| Use APNs auth key instead of p12 | Upload the .p8 key in Firebase console → Project Settings → iOS app |
| Custom notification icon (Android) | Set com.google.firebase.messaging.default_notification_icon in AndroidManifest.xml |
Next Steps
- Set up Permission Guard to track the permissions added by push notification SDKs
- Configure the Launch Tracker to time your push SDK initialization in the post-launch phase