notiblast_sdk 0.9.0
notiblast_sdk: ^0.9.0 copied to clipboard
Flutter push-notification SDK for the NotiBlast backend: FCM device registration, tray display, inline reply, and chat-style conversation notifications.
NotiBlast SDK #
The official Flutter SDK for NotiBlast — a push notification service you manage from the NotiBlast dashboard at app.notiblast.com.
- Website: https://notiblast.com/
- Dashboard: https://app.notiblast.com
A Flutter push-notification SDK for the NotiBlast backend, with a public API modeled after OneSignal. It registers devices with NotiBlast, receives Firebase Cloud Messaging (FCM) pushes, shows them in the notification tray (with grouping and images), and hands you a single, normalized click event no matter how the app was opened.
This README is the complete integration guide. Follow the steps in order — most "notifications don't arrive" problems come from a skipped step.
Table of Contents #
- How It Works
- Requirements
- Part 1 — Platform setup
- Part 2 — Using the SDK
- Part 3 — Optional features
- Complete Example
- API Reference
- Pre-Release Checklist
- Troubleshooting
How It Works #
NotiBlast.initAppId(appId)— called once inmain(). Prepares Firebase and stores your App ID. It does not register the device or show notifications yet.NotiBlast.Notifications.requestPermission()— asks the OS for permission. It does not contact the backend.- Listeners — you register click / foreground / error callbacks.
NotiBlast.login(userId)— activates notification handling and syncs the device with the NotiBlast backend, linked to your user ID. Call it on every cold start for a signed-in user.NotiBlast.logout()— detaches the user from the device. The device stays subscribed to broadcasts.
This SDK is designed for signed-in users. No device is registered with the NotiBlast backend before
login()is called.
Requirements #
Before you start, make sure you have:
- A working Flutter app targeting Android and/or iOS.
- Android:
minSdk21 or higher. - iOS: deployment target 15.0 or higher.
- Android:
- A Google account to create a Firebase project.
- A NotiBlast App ID — a 24-character hex string (for example
507f1f77bcf86cd799439011) for your app, available in the NotiBlast dashboard. - The platforms you target (Android and/or iOS) enabled for that App ID on the NotiBlast side.
- For iOS only: an Apple Developer account (to create an APNs key).
Important: every app needs its own Firebase project. FCM binds device tokens to your Android
applicationId/ iOS bundle ID, so you cannot reuse another app's Firebase project.
Part 1 — Platform Setup #
Step 1: Create a Firebase project #
- Open the Firebase Console and create a new project (or pick an existing one dedicated to this app).
- Click Add app and register each platform you target:
- Android — enter the exact
applicationIdfromandroid/app/build.gradle.kts(look forapplicationId = "..."). - iOS — enter the exact Bundle Identifier from Xcode (target Runner → General → Bundle Identifier).
- Android — enter the exact
- Download the config file Firebase gives you for each platform:
- Android:
google-services.json - iOS:
GoogleService-Info.plist
- Android:
Step 2: Add the Firebase config files #
The SDK initializes Firebase from these native files (it calls
Firebase.initializeApp() without options), so they are required. A
firebase_options.dart file generated by the FlutterFire CLI is not used by the SDK.
Android #
Place the file at:
android/app/google-services.json
iOS #
- Open
ios/Runner.xcworkspacein Xcode (notRunner.xcodeproj). - Drag
GoogleService-Info.plistinto the Runner folder in the Project Navigator. - In the dialog, check Copy items if needed and make sure the Runner target is checked under Add to targets.
Do not copy the file into
ios/Runner/with Finder or the terminal. If it is not added through Xcode, it is not part of the build and Firebase fails to start.
Step 3: Configure Android #
-
Declare the Google Services Gradle plugin in
android/settings.gradle.kts, inside theplugins { ... }block:plugins { // ...existing plugins id("com.google.gms.google-services") version "4.4.2" apply false }Older Flutter projects without a
pluginsblock insettings.gradle(.kts)declare it in the project-levelandroid/build.gradle(.kts)instead. -
Apply the plugin in
android/app/build.gradle.kts:plugins { // ...existing plugins id("com.google.gms.google-services") } -
Check
minSdkinandroid/app/build.gradle.ktsis at least21. If a newer dependency requires a higher value, the build error will tell you which one.
The Android 13+ notification permission (POST_NOTIFICATIONS) is already declared by
firebase_messaging — you do not need to edit AndroidManifest.xml.
Step 4: Configure iOS #
-
Set the iOS platform version in
ios/Podfile:platform :ios, '15.0' -
Enable capabilities — in Xcode, select target Runner → Signing & Capabilities → + Capability, then add:
- Push Notifications
- Background Modes, with Remote notifications checked
-
Upload an APNs key to Firebase — without this, no push ever reaches an iOS device:
- In the Apple Developer Portal, go to Certificates, Identifiers & Profiles → Keys and create a key with Apple Push Notifications service (APNs) enabled.
- Download the
.p8file. It can only be downloaded once — store it safely. - In the Firebase Console, open Project Settings → Cloud Messaging → Apple
app configuration, and upload the
.p8file together with your Key ID and Team ID.
-
Set the notification center delegate in
ios/Runner/AppDelegate.swift, insideapplication(_:didFinishLaunchingWithOptions:)and before thereturn:import Flutter import UIKit @main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate return super.application(application, didFinishLaunchingWithOptions: launchOptions) } }FlutterAppDelegateforwards notification callbacks to every registered plugin, so bothfirebase_messagingandflutter_local_notifications(used by this SDK) receive them.- Without this line, notifications still appear, but
addClickListeneris never called when a notification is tapped. - Do not use
FLTFirebaseMessagingPlugin.configureNotificationCenterDelegate(). It makesfirebase_messagingclaim the delegate for itself, and taps never reach the SDK. - Projects using the UIScene lifecycle (newer Xcode templates with
FlutterImplicitEngineDelegate): keep this line indidFinishLaunchingWithOptionsas shown — do not move it intodidInitializeImplicitFlutterEngine.
- Without this line, notifications still appear, but
-
Add a Notification Service Extension. On iOS, NotiBlast sends alert pushes that the OS displays itself — even when the app was force-quit. The extension lets the SDK customize them before they appear: title/body from the payload, grouping, and the image. Without it, notifications still arrive, but plain (no image).
-
Open
ios/Runner.xcworkspace(not.xcodeproj). Choose File → New → Target → Notification Service Extension, name itNotificationService, pick the same Team as Runner, and click Finish. When Xcode asks to activate the scheme, choose Cancel. -
Check the new target's settings — select target NotificationService → Build Settings → All, and compare every configuration (Debug, Release, Profile, and each flavor):
Setting Value iOS Deployment Target 15.0— Xcode defaults to the newest iOS, which hides the extension on older devicesProduct Bundle Identifier Runner's bundle ID for that configuration + .NotificationServiceMarketing Version / Current Project Version Leave the defaults ( 1.0/1). Do not use$(FLUTTER_BUILD_NAME)— it resolves to empty in an extension target and the build failsUsing flavors? Xcode gives the extension a single bundle ID for all configurations, but an extension's ID must start with its app's ID. If Runner is
com.example.appfor prod andcom.example.app.stagingfor staging, set the extension tocom.example.app.NotificationServiceandcom.example.app.staging.NotificationServicerespectively — otherwise the staging build fails to install.The extension needs no capabilities (no Push Notifications, no App Groups) — Push Notifications stays on Runner only.
-
Check the build phase order — select target Runner → Build Phases. Embed Foundation Extensions must sit above Thin Binary (drag it up if not). Otherwise the build fails with
Cycle inside Runner. -
Replace the generated
NotificationService/NotificationService.swiftwith:import NotiBlastNSE class NotificationService: NotiBlastNotificationService {} -
Add the extension target to
ios/Podfile, outsidetarget 'Runner':target 'NotificationService' do use_frameworks! pod 'NotiBlastNSE', :path => '.symlinks/plugins/notiblast_sdk/ios' end -
Run
flutter pub get, thencd ios && pod install. -
Verify: build and run the app on a real device, force-quit it, and send a push with an
image_url. The notification should arrive with the image.
NotiBlastNSEhas no Flutter dependency, so it is safe inside an extension. Image URLs must behttps— iOS blocks plainhttpin extensions, and the notification is then shown without the image.Using notification replies? See Replying from a notification for the extra
AppDelegateline and the optionalcanReplyoverride in this extension. Chat app? See Chat notifications on iOS for the optionalcommunicationoverride. -
Step 5: Install the package #
The SDK is installed straight from its GitLab repository — it is not published on
pub.dev. Add it to your app's pubspec.yaml, pinned to a release tag, and run
flutter pub get:
dependencies:
notiblast_sdk:
git:
url: https://gitlab.com/bitaplikasi-public/notiblast/notiblast-flutter-sdk.git
ref: v0.8.0
Or with a single command:
flutter pub add notiblast_sdk \
--git-url=https://gitlab.com/bitaplikasi-public/notiblast/notiblast-flutter-sdk.git \
--git-ref=v0.8.0
ref is a release tag — see CHANGELOG.md for what each version contains. To upgrade,
change ref to a newer tag and run flutter pub get. Avoid ref: main: it follows
unreleased work, so the next flutter pub upgrade can pull in breaking changes.
Part 2 — Using the SDK #
All examples import the package:
import 'package:notiblast_sdk/notiblast_sdk.dart';
Step 6: Initialize the SDK #
Call NotiBlast.initAppId() once in main(), before runApp():
import 'package:flutter/material.dart';
import 'package:notiblast_sdk/notiblast_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // required — the SDK uses native plugins
await NotiBlast.initAppId('YOUR_NOTIBLAST_APP_ID');
runApp(const MyApp());
}
What initAppId() does:
- Stores your App ID and initializes Firebase.
- Starts watching for FCM token changes. If Firebase issues a new token while a user is logged in, the SDK re-registers the device automatically.
What it does not do: register the device or activate notifications. Both happen in
login().
Step 7: Request notification permission #
Call this after initAppId() and before login():
final granted = await NotiBlast.Notifications.requestPermission();
-
On Android below 13 (API 33), this returns
trueimmediately without a dialog — that is normal OS behavior. -
It only asks the OS. The result is sent to the backend by
login():Permission when login()runsWhat login()doesGranted Registers the device with your user ID Denied Reports the denial to the backend Never asked Sends nothing
Order matters: call
requestPermission()beforelogin()on every cold start. Once granted, the dialog does not show again, so this is safe. If permission is granted only afterlogin()(for example, you delay the dialog until onboarding ends), the device registers on the nextlogin()— usually the next app start.
To read the current status without prompting, use
await NotiBlast.Notifications.permission.
Step 8: Register listeners #
Register listeners before calling login() — for example in initState of your
first screen:
late final void Function(NotiBlastClickEvent) _onClick;
late final void Function(NotiBlastNotification) _onForeground;
late final void Function(DeviceRegistrationException) _onRegistrationError;
@override
void initState() {
super.initState();
// The user tapped a notification (app in background, app terminated,
// or a notification the SDK showed while the app was open).
_onClick = (event) {
final notification = event.notification;
// Navigate using notification.clickAction / notification.additionalData.
// The SDK never navigates on its own.
};
NotiBlast.Notifications.addClickListener(_onClick);
// A notification arrived while the app is in the foreground.
// It is NOT shown in the tray automatically — call showNotification() to show it.
_onForeground = (notification) {
notification.showNotification();
};
NotiBlast.Notifications.addForegroundListener(_onForeground);
// Background backend failures from login() or automatic re-registration
// (e.g. wrong App ID, rate limit). login() itself never throws for these.
_onRegistrationError = (error) {
debugPrint('NotiBlast registration failed: $error');
};
NotiBlast.Notifications.addRegistrationErrorListener(_onRegistrationError);
}
@override
void dispose() {
NotiBlast.Notifications.removeClickListener(_onClick);
NotiBlast.Notifications.removeForegroundListener(_onForeground);
NotiBlast.Notifications.removeRegistrationErrorListener(_onRegistrationError);
super.dispose();
}
Listeners registered in a widget that can be disposed must be removed in dispose(),
otherwise they stack up and fire once per registration; anonymous closures cannot be
removed, so keep a reference as shown above.
Every listener receives a NotiBlastNotification (the click listener gets it as
event.notification) with these fields:
| Field | Type | Description |
|---|---|---|
title |
String? |
Notification title |
body |
String? |
Notification body |
clickAction |
String? |
Free-form value set by your backend (e.g. a route name) |
additionalData |
Map<String, dynamic> |
Custom business data, already JSON-decoded. Empty map if absent or invalid |
groupId |
String? |
Group key, if the notification is grouped |
imageUrl |
String? |
Image URL, if any |
data |
Map<String, dynamic> |
Raw FCM data payload |
Foreground notifications are not auto-displayed. Notifications received while the app is in the background or terminated are shown automatically. In the foreground, they only reach
addForegroundListener— callnotification.showNotification()if you want them in the tray, or skip it to hide one (e.g. the user is already on that chat). On iOS this includes alert pushes: the SDK holds back the system banner so your listener decides. Beforelogin()has wired the listeners, a foreground push is not shown.
Background and terminated-state notifications are always shown by the SDK. Do not register
your own FirebaseMessaging.onBackgroundMessage() handler: the SDK registers its handler
during NotiBlast.login(), which replaces yours. To keep a notification from reaching a
user, filter it on the backend.
Step 9: Log the user in #
After your own authentication succeeds, and after the listeners from Step 8 are registered:
await NotiBlast.Notifications.requestPermission(); // Step 7
await NotiBlast.login(currentUserId);
login():
- Activates notification handling (FCM listeners, local notifications, tap handling), once per app process.
- Syncs the device with the NotiBlast backend based on the permission status (see the table in Step 7), linking it to your user ID.
- Runs the backend call in the background — it never delays
login(). Failures are delivered toaddRegistrationErrorListener. - Skips the backend call if nothing changed since the last successful registration in the same app session.
Call
login()on every cold start for a user who is still signed in. The SDK keeps the user ID in memory only — it is not restored on the next launch. If your app skipslogin()because the user is "already signed in" in your own auth system, no notifications (including background pushes) will be handled in that session.
A device registered in an earlier session can still be targeted by backend broadcasts
before login() runs, but the SDK does not display them until login() is called.
Step 10: Log the user out #
When the user signs out of your app:
await NotiBlast.logout();
- Detaches the user ID from this device on the backend. The device stays subscribed and keeps receiving broadcasts.
- To stop notifications completely on logout, also call
NotiBlast.Notifications.optOut(). - Local SDK state is always cleared, even if the backend call fails. On failure,
logout()throwsDeviceRegistrationExceptionafter clearing state, so you can catch it to log or retry. - Safe to call even if
login()was never called. - Also removes what the SDK stored for chat notifications: the conversation shortcuts on Android and the donated messages on iOS.
Part 3 — Optional Features #
Opt-out / opt-in #
For a "Receive notifications" toggle in your app settings:
// Stop push notifications for this device (regardless of login state).
await NotiBlast.Notifications.optOut();
// Resume — re-registers the device (with the user ID, if logged in).
await NotiBlast.Notifications.optIn();
- Both throw
DeviceRegistrationExceptionif the backend call fails, andNotInitializedExceptionif called beforeinitAppId(). optIn()does nothing (and does not throw) if permission is not granted or no FCM token exists yet. The device then registers on the nextlogin().
The SDK does not persist the opt-out state. Any later registration —
login(), an FCM token change, oroptIn()— subscribes the device again. If your user opts out, store that preference yourself and calloptOut()again afterlogin()on every cold start.
Topic subscriptions #
Subscribe devices to FCM topics (for example promo or news-en) to receive topic
broadcasts:
try {
await NotiBlast.Notifications.subscribeTopic('promo');
} on TopicSubscriptionException catch (e) {
// Network error, invalid topic name, or (iOS) APNs token not ready — retry later.
}
await NotiBlast.Notifications.unsubscribeTopic('promo');
- Requires only
initAppId()—login()is not required, so guest users can subscribe. Calling beforeinitAppId()throwsNotInitializedException. - Topics are handled by FCM directly. The SDK makes no NotiBlast backend call and does not store the list of subscribed topics.
- Topic names must match
[a-zA-Z0-9-_.~%]{1,900}. Invalid names are rejected by FCM as aTopicSubscriptionException. - iOS: subscribing fails if the APNs token is not ready yet (common right after
requestPermission()). Subscribe afterlogin()and retry on failure.
Topics are independent of opt-out and logout. Topic pushes are not blocked by
optOut(), and topics are not left onlogout()— callunsubscribeTopic()yourself. Subscriptions are also lost when the FCM token changes (reinstall, cleared app data), so subscribe again on every cold start. Subscribing to the same topic twice is harmless.
Clearing notifications from the tray #
// Remove every notification shown by this SDK.
await NotiBlast.Notifications.clearAll();
// Remove all notifications in one group (e.g. after the user reads a chat room).
await NotiBlast.Notifications.removeGroupedNotifications('chat_room_42');
removeGroupedNotifications():
- Is a no-op on Android below 6.0 (API 23) — the OS cannot list active notifications.
- Throws
ArgumentErrorif the group key is empty, andNotificationManagementExceptionif the native call fails.
Getting the FCM token #
If you need this device's FCM token for your own purposes (debugging, another system):
final String? token = await NotiBlast.Notifications.getToken();
Available any time after initAppId() — login() is not required. Calling it before
initAppId() throws NotInitializedException.
Grouping and images #
No app code is needed. Once your backend includes the fields below in the push payload, the SDK handles them automatically:
- Grouping — notifications sharing the same
group_keyare stacked into one group (Android) or thread (iOS). On Android the SDK also posts one silent summary pergroup_key(updated with the latest notification), so only the new notification makes a sound. The backend no longer needs to send a summary itself. - Images — if
image_urlis set, the SDK downloads the image and shows it as a big picture (Android) or attachment (iOS). In a conversation notification it is shown inside the message bubble instead. If the download fails, the notification is still shown, just without the image. On Android 12+ an animated GIF or WebP big picture plays (the notification is reposted silently with the animated image); on older versions, or if that repost fails, the first frame is shown. On iOS, images in formats iOS can't attach (WebP, BMP, AVIF, ...) are converted to JPEG, so animated WebP shows as a still image; SVG is not supported.
On iOS, both need the Notification Service Extension from
Step 4 — grouping also works without it if the backend sets
aps.thread-id. The backend sends a different message shape per platform (data-only for
Android, an alert with mutable-content: 1 for iOS); the NotiBlast team has the payload
contract.
Replying from a notification #
Users can type a reply in the notification itself, without opening your app (like a chat app). The SDK gives you the text and the notification; sending it to your server is up to you.
// Must be a top-level (or static) function with this annotation.
@pragma('vm:entry-point')
Future<void> onReply(NotiBlastReplyEvent event) async {
// event.text — what the user typed (not trimmed)
// event.notification.data — the full data of the notification
await sendToServer(event.notification.data, event.text);
}
await NotiBlast.initAppId('YOUR_APP_ID');
await NotiBlast.Notifications.setReplyHandler(onReply);
await NotiBlast.login(userId);
This makes every notification replyable. To limit it, pass canReply — a function
that gets each notification about to be shown and returns whether it can be replied to.
You decide the rule; for example, only group chats:
// Same requirements as the handler: top-level (or static) and annotated.
@pragma('vm:entry-point')
bool canReply(NotiBlastNotification notification) =>
notification.groupId?.startsWith('groupChat-') ?? false;
await NotiBlast.Notifications.setReplyHandler(
onReply,
canReply: canReply,
actionTitle: 'Reply', // label of the action
placeholder: 'Message', // hint in the text field
);
notification.data holds the full payload, and string values that contain JSON are
already decoded, so nested fields work too
(notification.data['service']?['serviceType'] == 'groupChat').
The rule doesn't have to use group_key — any field of the notification works:
// By a data field, including fields inside a JSON-encoded value.
@pragma('vm:entry-point')
bool canReply(NotiBlastNotification notification) {
final service = notification.data['service']; // JSON string → already a Map
return service is Map && service['serviceType'] == 'groupChat';
}
// Or combine fields.
@pragma('vm:entry-point')
bool canReplyToChat(NotiBlastNotification notification) =>
notification.data['type'] == 'chat' &&
notification.data['is_system'] != 'true';
FCM sends top-level data values as strings, so compare them as strings ('true',
'5'). Values inside a decoded JSON field keep their JSON types (numbers, booleans).
canReply can only look at the notification itself — not app state or your server (for
example "is the user still in this room?").
Things to know:
- No payload change is needed.
canReplyreads the data your backend already sends. - All functions need
@pragma('vm:entry-point')(handler,canReply, anddismissOnReply).setReplyHandler()checks this when you call it: if the annotation is missing, the call fails right there with an engine error that names the function, and the code after it does not run. The error cannot be caught — add the annotation. - Both functions run in a background isolate on Android — the handler always, even
while the app is open, and
canReplywhenever a notification arrives while the app is closed. They cannot use variables, singletons, or UI state from your running app. On iOS the handler runs in your app's main isolate, but possibly with no UI (see below). - Keep
canReplysynchronous, fast, and pure — only read the notification. If it throws, the notification is still shown, just without the reply action. - Call
setReplyHandler()afterinitAppId()on every app start, at the top of yourmain()flow and beforelogin().initAppId()clears the stored settings, so a version of your app that stops calling it no longer shows the reply action. main()must run fine without a screen. On iOS, a reply to a closed app starts your app in the background with no UI:main()runs, and the reply is delivered oncesetReplyHandler()is called. Don't show dialogs or wait for UI before that call.- On iOS, push-token work waits until the app is opened. Reading the FCM / APNs
token while iOS runs the app in the background (e.g. right after a reply to a closed
app) breaks the APNs token mapping in FCM, and later pushes fail with
BadDeviceTokenuntil the app is opened. So in that state the SDK defers every token operation — device registration fromlogin(), token refresh,logout(),optIn()/optOut(), topic (un)subscribe andNotifications.getToken()— until the app becomes active.login()itself still completes right away, and replies are delivered as usual. - The text field and send button are drawn by the OS; only the labels can be changed.
- After a reply, the notification stays in the tray on Android (a conversation
notification also shows the reply) — unless
dismissOnReplyremoves it, see below. On iOS it is removed from the tray, and the app stays closed or in the background. - A reply never triggers
addClickListener, on either platform. - If your handler throws, the SDK logs the error and does not retry — handle offline cases yourself. Keep the handler short.
- No extra Android setup is needed. iOS needs the setup below.
Removing the notification after a reply (Android) #
By default a replied notification stays in the tray on Android. To remove some of them
once the reply has been sent, pass dismissOnReply — it gets the replied notification
and returns whether to remove it. For example, remove comment notifications but keep
chats:
// Same requirements as canReply: top-level (or static) and annotated.
@pragma('vm:entry-point')
bool dismissOnReply(NotiBlastNotification notification) =>
notification.data['type'] == 'comment';
await NotiBlast.Notifications.setReplyHandler(
onReply,
dismissOnReply: dismissOnReply,
);
- It runs only after your handler finished without throwing. If the handler throws, the notification stays so the user can try again — so rethrow errors (e.g. a failed request) instead of catching them silently, or the notification is removed even though the reply was not sent.
- If the removed notification was the last one in its group, the group summary is removed too. Other notifications in the same group are not touched.
- It works the same for conversation notifications: returning
trueremoves the conversation, including its message history. - If it throws, the notification stays and the error is logged.
- iOS ignores it — iOS already removes a replied notification.
iOS setup #
On iOS the user long-presses (or pulls down) the notification to reply.
-
Call
NotiblastSdkPlugin.setupReply()inios/Runner/AppDelegate.swift, insideapplication(_:didFinishLaunchingWithOptions:)and before thereturn:import Flutter import UIKit import notiblast_sdk @main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate NotiblastSdkPlugin.setupReply() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } }When the user replies while the app is closed, iOS starts the app in the background, and the SDK must be listening before launch finishes. Without this call, replies only work while the app is running (
setReplyHandler()logs a warning). Set the notification center delegate in the same method, as shown — not later (e.g. not in a Flutter engine or scene callback) — so the SDK can sit in front of it. -
Optional: limit which pushes get Reply in the background. While the app is in the background or closed, iOS pushes are prepared by the Notification Service Extension (Step 4), not by Dart. By default every push gets Reply; to limit it, override
canReply(groupKey:data:)in your extension:import NotiBlastNSE class NotificationService: NotiBlastNotificationService { override func canReply(groupKey: String?, data: [String: Any]) -> Bool { groupKey?.hasPrefix("groupChat-") ?? false } }datahas the same shape asNotiBlastNotification.datain Dart (FCM keys removed, JSON string values decoded). Keep this logic identical to your DartcanReply— otherwise a notification can have Reply in the foreground but not in the background.The rule can use any field, just like in Dart. The Swift versions of the two Dart examples above:
// By a field inside a JSON-encoded value (already a dictionary here). override func canReply(groupKey: String?, data: [String: Any]) -> Bool { (data["service"] as? [String: Any])?["serviceType"] as? String == "groupChat" } // Or combine fields — top-level values are strings, as in Dart. override func canReply(groupKey: String?, data: [String: Any]) -> Bool { data["type"] as? String == "chat" && data["is_system"] as? String != "true" }
Things to know (iOS):
- No payload change is needed — no
aps.category.mutable-content: 1is already required for the extension. If the server does send thenotiblast_replycategory, the extension removes it whencanReplyreturnsfalse. - Late, not lost. iOS gives the app limited background time. If your handler isn't
reached within about 25 seconds (e.g.
main()is slow), the reply is stored on the device and delivered to your handler on the next app start.
Temporary notifications #
Pass timeoutAfter to showNotification() to show a notification that removes itself
from the tray after a while — handy for short status notices. For example, telling the
user a reply failed, from inside the reply handler:
@pragma('vm:entry-point')
Future<void> onReply(NotiBlastReplyEvent event) async {
final original = event.notification;
try {
await sendToServer(original.data, event.text);
} catch (_) {
await NotiBlastNotification(
title: 'Reply not sent',
body: 'Your reply could not be sent',
data: {...original.data, 'replyFailed': true},
groupId: original.groupId,
).showNotification(timeoutAfter: const Duration(seconds: 2));
}
}
Things to know:
- Android: the OS removes the notification, so it works even after the background isolate running your handler has exited.
- iOS: the SDK removes it, keeping the app alive with a background task while it waits (iOS grants about 30 seconds, so keep durations short — a longer one is cut short when that time runs out). Best-effort: if iOS kills the app first, the notification stays until dismissed.
- The rest of the tray is left alone. A temporary notification keeps its
groupId, so it stacks with that group, but it doesn't post or update the group summary. When it disappears, the summary and the other notifications in the group (including a conversation notification) stay as they were. - Always a plain notification: it never becomes a conversation notification and never
gets the reply action, even if
toConversation/canReplywould match it. - A zero or negative duration is ignored (the notification stays, a warning is logged).
- Notifications from push messages are not affected — this only applies to
showNotification().
Conversation notifications (Android) #
Show chosen notifications as a conversation: one notification per chat that lists the
latest messages, like a chat app (Android MessagingStyle). Notifications you don't
choose look exactly as before. For iOS, see
Chat notifications on iOS.
You decide which notifications become conversations with a function that turns a
notification into a NotiBlastConversation, or returns null to keep it regular:
// Same requirements as the reply handler: top-level (or static) and annotated.
@pragma('vm:entry-point')
NotiBlastConversation? toConversation(NotiBlastNotification n) {
final room = n.groupId;
// Any rule you like: a prefix, a fixed list, a field inside data...
if (room == null || !room.startsWith('groupChat-')) return null;
return NotiBlastConversation(
conversationId: room, // one notification per ID
conversationTitle: n.data['room_name'] as String?,
senderId: n.data['sender_id'] as String,
senderName: n.data['sender_name'] as String,
senderAvatarUrl: n.data['sender_avatar'] as String?,
// Icon of the conversation (Android 11+): group logo, the other person's photo...
conversationAvatarUrl: n.data['room_avatar'] as String?,
// Initials when there's no avatar image (default: conversationTitle).
avatarName: n.data['team_name'] as String?,
text: n.body ?? '',
isGroup: true,
// Optional: override the default style for this conversation only.
style: const NotiBlastConversationStyle(maxMessages: 5),
);
}
await NotiBlast.initAppId('YOUR_APP_ID');
await NotiBlast.Notifications.setConversationHandler(
toConversation,
style: const NotiBlastConversationStyle(
maxMessages: 10, // latest messages kept, 1–25 (default 10)
selfName: 'Me', // label for the user's own replies (default 'You')
selfAvatarUrl: null, // avatar for the user's own replies
showAvatar: true, // sender avatars (default true)
defaultAvatarDrawable: 'ic_avatar', // drawable used when an avatar is missing
useGroupSummary: false, // also post a group_key summary (default false)
avatarShape: NotiBlastAvatarShape.circle, // round avatars (default) or .original
),
);
await NotiBlast.login(userId);
// When the user opens that chat in your app:
await NotiBlast.Notifications.clearConversation('groupChat-692d');
Things to know:
- No payload change is needed.
toConversationreads the data your backend already sends. It runs in a background isolate when the app is closed, so the same rules ascanReplyapply:@pragma('vm:entry-point'), synchronous, no app state. If it throws or returns an emptyconversationId,senderId,senderName, ortext, the notification is shown as a regular one. - Field by field style. A
nullfield in a conversation'sstyleuses the default style, and anullfield there uses the SDK default. - The messages live in the notification. Dismissing it, or calling
clearConversation(), starts the conversation over. - Replies are added to the conversation. Replies use
setReplyHandler()and itscanReplyas usual. A reply your handler accepted without throwing shows up as a message fromselfName; a failed one is not added. - Avatars are downloaded once per sender and cached; if a download fails, the cached
avatar or
defaultAvatarDrawableis used, and the notification is still shown. Downloaded avatars are resized to 192×192 and cropped round (NotiBlastAvatarShape.circle, the default) or kept square (NotiBlastAvatarShape.original). If that processing fails, the downloaded image is used as is. - Images. If the push has an
image_url, the image is shown inside that message's bubble, and older messages keep their image when new ones arrive.textis still required: return a caption or a placeholder such as'📷 Photo'for image-only messages. If the image can't be downloaded, the message is shown as text only. No setup is needed in your app: the SDK's manifest declares its ownFileProvider. Image files are kept in the app's cache for 7 days, andclearConversation()deletes that conversation's images. - Android 11+ conversation look. The SDK links each conversation to a long-lived
shortcut (ID =
conversationId), so the notification appears in the Conversations section of the shade withconversationAvatarUrlas its large icon and a small app icon badge, collapsed and expanded. You choose the image — the SDK never falls back tosenderAvatarUrl. It is downloaded once per URL and cached; if it isnullor fails,defaultAvatarDrawableis used, then a generated avatar with the initials (first letters of the first two words, e.g. "Team Cicle" → "TC") ofavatarName, else the conversation title, on a colored background. SetavatarNamewhen the title is generic (e.g. "Group Chat - Team Cicle" →avatarName: 'Team Cicle'). Android masks the icon (usually to a circle), so the SDK shrinks the image onto a background of its top-left pixel's color (white if transparent) to keep logo edges visible. Recent conversations can also show in the app icon's long-press menu, as with WhatsApp — Android needs that to accept the shortcut, and limits how many are kept.NotiBlast.logout()removes them. Below Android 11 the notification keeps its old look. defaultAvatarDrawableis shown as is, never cropped — use a drawable that is already round (a transparent PNG or a vector) if you want it round.- Call
setConversationHandler()afterinitAppId()on every app start, beforelogin().clearConversationHandler()turns the feature off. - Messages arriving together. Pushes for the same conversation are shown one after another, so two messages arriving at the same moment both appear. Messages are listed in the order the pushes arrive, which can differ from the order they were sent.
- Limits: below Android 6.0 (API 23) a conversation only shows the latest message.
Chat notifications on iOS #
On iOS 15+, chat pushes can be shown as communication notifications, like WhatsApp: the sender's or group's avatar with a small app icon badge, the group or sender name as the title, then the message. Pushes you don't choose look exactly as before.
iOS shows pushes through the
Notification Service Extension, which can't run Dart, so you
choose chat pushes in Swift — the same pattern as canReply:
import NotiBlastNSE
class NotificationService: NotiBlastNotificationService {
override func communication(groupKey: String?, data: [String: Any]) -> NotiBlastCommunication? {
// Same rule as your Dart toConversation.
guard let room = groupKey, room.hasPrefix("groupChat-") else { return nil }
guard let senderId = data["sender_id"] as? String,
let senderName = data["sender_name"] as? String else { return nil }
return NotiBlastCommunication(
conversationId: room,
senderId: senderId,
senderName: senderName,
conversationTitle: data["room_name"] as? String, // group name; nil for 1-to-1
avatarURL: (data["room_avatar"] as? String).flatMap(URL.init), // group: group avatar; 1-to-1: sender photo
avatarName: data["team_name"] as? String, // initials when no avatar (optional)
isGroup: true
// initialsFallback: false — no initials; a missing avatar shows the app icon
)
}
}
Setup — without all three, pushes still arrive but look as before:
-
Target Runner → Signing & Capabilities → + Capability → Communication Notifications (adds
com.apple.developer.usernotifications.communicationto the entitlements). Enable the same capability for the App ID in the Apple Developer Portal and regenerate the provisioning profiles. -
ios/Runner/Info.plist:<key>NSUserActivityTypes</key> <array> <string>INSendMessageIntent</string> </array> -
ios/NotificationService/Info.plist, inside the existingNSExtensiondictionary:<key>NSExtensionAttributes</key> <dict> <key>IntentsSupported</key> <array> <string>INSendMessageIntent</string> </array> </dict>
Then run cd ios && pod install.
Things to know:
- Keep the rule in sync with Dart.
communicationdecides the look of pushes shown by iOS (app in the background or closed);toConversationonly applies to Android. - Required fields. An empty
conversationId,senderId, orsenderNameshows the push as a regular notification. - Avatars.
avatarURLis downloaded (5 s timeout,httpsonly) alongsideimage_url, converted to PNG (at most 256 px — iOS can't show WebP and other formats as an avatar; SVG or other undecodable content uses the initials avatar), then cached per URL in the extension's caches directory for 30 days (at most 100 files / 20 MB, oldest removed first). A cached avatar is used without a download; after 30 days it is downloaded again, and the old copy is used if that fails.logout()doesn't clear this cache: avatar URLs are public and the cache lives in the extension's container, not the app's. If it isnilor fails, the SDK draws an initials avatar — first letters of the first two words ("Team Cicle" → "TC") ofavatarName, else the group name (group) or sender name (1-to-1), on a colored circle — since iOS would show the app icon. PassinitialsFallback: falseto keep the app icon instead, e.g. when the avatar is a company logo rather than a person. A group withoutconversationTitleuses the notification title as the group name. In a group, the avatar is shown as the sender's image — iOS only displays the sender image — so pass the group's avatar there, not a person's photo. - Foreground. While the app is open, iOS shows these pushes itself, with the same
look;
addForegroundListeneris still called, butshowNotification()does nothing for them (a local copy would be a duplicate without the look). The NSE marks them withdata['notiblast_communication'] == 'true'. Other pushes behave as before. - Reply, grouping, and images still work. The Reply action,
group_keythread, andimage_urlattachment are kept. NotiBlast.logout()deletes the messages donated to iOS for these notifications (they feed Siri and share sheet suggestions).- Limits: iOS 15+ only. Data-only pushes (no NSE) keep the regular look.
Seeing SDK logs #
By default the SDK logs through dart:developer, which only shows up while a debugger is
attached. To see SDK logs in Release / TestFlight builds (e.g. in Console.app or logcat),
set a log handler first thing in main():
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
NotiBlast.setLogHandler((NotiBlastLogRecord record) {
// record.level, record.module, record.message, record.error, record.stackTrace
print(record); // "[NotiBlast.<module>] info: <message>"
});
await NotiBlast.initAppId('YOUR_APP_ID');
// ...
}
- Can be set before
initAppId(); passnullto remove it. - If the handler throws, the SDK catches it and keeps going.
- Logs never contain full push tokens, only their last 6 characters (e.g.
tokens@login: fcm=…abc123, apns=…9f0e1d), so they are safe to collect. - Logs written in Android background isolates (background messages, notification replies) don't reach the handler.
Complete Example #
import 'package:flutter/material.dart';
import 'package:notiblast_sdk/notiblast_sdk.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await NotiBlast.initAppId('YOUR_NOTIBLAST_APP_ID');
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
// 1. Register listeners before login().
// Root widget: these listeners live for the whole app, so no removal needed.
// In a screen that can be closed, remove them in dispose() — see Step 8.
NotiBlast.Notifications.addClickListener((event) {
debugPrint('Clicked: ${event.notification.title}');
});
NotiBlast.Notifications.addForegroundListener((notification) {
notification.showNotification();
});
NotiBlast.Notifications.addRegistrationErrorListener((error) {
debugPrint('Registration failed: $error');
});
}
// 2. Call after your own sign-in succeeds, AND on every cold start
// while the user is still signed in.
Future<void> _onSignedIn(String userId) async {
await NotiBlast.Notifications.requestPermission();
await NotiBlast.login(userId);
}
// 3. Call when the user signs out.
Future<void> _onSignedOut() async {
await NotiBlast.logout();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My App')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () => _onSignedIn('user-123'),
child: const Text('Sign in'),
),
ElevatedButton(
onPressed: _onSignedOut,
child: const Text('Sign out'),
),
],
),
),
);
}
}
A runnable smoke-test app is available in example/.
API Reference #
NotiBlast #
| Member | Description |
|---|---|
Future<void> initAppId(String appId) |
Initialize the SDK. Call once in main() before runApp() |
Future<void> login(String userId) |
Activate notifications and link this device to the user. Call on every cold start |
Future<void> logout() |
Unlink the user from this device. Throws DeviceRegistrationException on backend failure |
void setLogHandler(void Function(NotiBlastLogRecord)? handler) |
Receive SDK log lines (see Seeing SDK logs). Safe before initAppId() |
NotiBlast.Notifications |
Access to the notification API below |
NotiBlast.Notifications #
| Member | Description |
|---|---|
Future<bool> requestPermission() |
Ask the OS for notification permission |
Future<bool> permission |
Current permission status, without prompting |
void addClickListener(...) |
Called when a notification is tapped |
void addForegroundListener(...) |
Called when a notification arrives in the foreground |
void addRegistrationErrorListener(...) |
Called on background registration failures |
void removeClickListener(...) |
Stop a click listener (pass the same function you added) |
void removeForegroundListener(...) |
Stop a foreground listener |
void removeRegistrationErrorListener(...) |
Stop a registration-error listener |
Future<void> setReplyHandler(handler, {canReply, dismissOnReply, actionTitle, placeholder, sendButtonTitle}) |
Let users reply from the notification; handler receives a NotiBlastReplyEvent. dismissOnReply removes chosen notifications after a successful reply (Android). Throws ArgumentError if handler, canReply, or dismissOnReply is not a top-level or static function |
Future<void> setConversationHandler(toConversation, {style}) |
Show chosen notifications as Android conversations; toConversation returns a NotiBlastConversation or null. Throws ArgumentError if it is not a top-level or static function |
Future<void> clearConversationHandler() |
Stop showing notifications as conversations |
Future<void> clearConversation(String) |
Remove one conversation (and its messages) from the tray. Android only |
Future<void> optOut() / optIn() |
Stop / resume push notifications for this device |
Future<void> subscribeTopic(String) / unsubscribeTopic(String) |
Join / leave an FCM topic |
Future<void> clearAll() |
Remove all SDK notifications from the tray |
Future<void> removeGroupedNotifications(String) |
Remove one group from the tray |
Future<String?> getToken() |
This device's FCM token |
iOS (Swift) #
| Member | Description |
|---|---|
NotiblastSdkPlugin.setupReply() |
Enable replies while the app is closed. Call in AppDelegate's didFinishLaunchingWithOptions, before return (iOS setup) |
NotiBlastNotificationService.canReply(groupKey:data:) |
Override in your Notification Service Extension to choose which pushes get Reply in the background. Default: every push |
NotiBlastNotificationService.communication(groupKey:data:) |
Override to show chosen pushes as iOS 15+ communication notifications (Chat notifications on iOS). Default: nil (regular) |
NotiBlastCommunication |
Conversation, sender, group name, avatar URL, isGroup, and initialsFallback returned by communication |
Exceptions #
| Exception | Thrown when |
|---|---|
NotInitializedException |
A method needing initialization is called before initAppId() |
DeviceRegistrationException |
A backend call fails (logout, optOut, optIn; delivered to addRegistrationErrorListener for login) |
TopicSubscriptionException |
FCM rejects a topic request |
NotificationManagementException |
A native tray operation fails |
Pre-Release Checklist #
- ❌
google-services.jsonand/orGoogleService-Info.plistcome from your app's own Firebase project (not a sample or dummy file). - ❌ The APNs key is uploaded to Firebase (iOS only).
- ❌
AppDelegate.swiftsetsUNUserNotificationCenter.current().delegate = self(iOS only). - ❌ The
NotificationServiceextension target exists and usesNotiBlastNSE(iOS only) — a test push with animage_urlshows the image. - ❌ Using replies:
AppDelegate.swiftcallsNotiblastSdkPlugin.setupReply(), and the extension'scanReply(groupKey:data:)matches your DartcanReply(iOS only). - ❌ Your NotiBlast App ID is confirmed with the NotiBlast team, and every platform you ship is enabled for it.
- ❌ A test message from the Firebase Console arrives on a real device.
- ❌ The full flow works end to end: open app → grant permission → sign in (device
appears registered and linked to your user ID) → sign out (user unlinked, device
still subscribed) →
optOut()/optIn()if you use a toggle. - ❌
login()is called on every cold start for signed-in users.
Troubleshooting #
| Problem | Likely cause and fix |
|---|---|
No notifications at all, even though initAppId() was called and permission is granted |
initAppId() does not activate notifications. Call login() — and call it again on every cold start for signed-in users (Step 9). |
| Device is not registered on the backend | Registration only happens in login(), and only if permission is already granted. Call requestPermission() before login(). Check addRegistrationErrorListener for backend errors (see rows below). |
Registration error Push application not found |
Wrong App ID. Confirm it with the NotiBlast team. |
Registration error Platform "ios" is not enabled for this push application |
That platform is not enabled for your App ID. Ask the NotiBlast team to enable it. |
Registration error Too many requests |
Backend rate limit (60 requests per 15 minutes per IP). Retry later. |
| Device only registers on the next app start | Permission was granted after login(). Call requestPermission() before login() (Step 7). |
addClickListener never fires on tap |
iOS: AppDelegate.swift must set UNUserNotificationCenter.current().delegate = self and must not call FLTFirebaseMessagingPlugin.configureNotificationCenterDelegate() (Step 4). Also make sure listeners are registered before login(). |
| A listener callback fires several times for one notification | The listener was registered more than once — typically in initState() of a screen that is opened repeatedly. Keep a reference and call the matching removeXxxListener() in dispose() (Step 8). |
| No pushes arrive on iOS | APNs key not uploaded to Firebase, or Push Notifications / Background Modes → Remote notifications not enabled in Xcode (Step 4). Backend must send iOS messages as alert pushes (aps.alert + mutable-content: 1). |
Build fails with Cycle inside Runner after adding the extension |
In target Runner → Build Phases, move Embed Foundation Extensions above Thin Binary (Step 4). |
| Staging/other flavor fails to install after adding the extension | The extension's bundle ID for that configuration doesn't start with the app's bundle ID (Step 4). |
| iOS notification shows no image | The NotificationService extension is missing or doesn't subclass NotiBlastNotificationService (Step 4), or image_url is not https. |
iOS build fails: a pod's IPHONEOS_DEPLOYMENT_TARGET (e.g. 12.0) is outside the supported range |
Newer Xcode drops support for old iOS targets that some pods still declare. In ios/Podfile, inside post_install → installer.pods_project.targets.each do |target|, add: target.build_configurations.each { |c| c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' if c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0 }, then pod install. |
| iOS reply only works while the app is running | AppDelegate.swift must call NotiblastSdkPlugin.setupReply() in didFinishLaunchingWithOptions (iOS setup). |
| iOS notification has Reply in the foreground but not in the background (or the other way around) | The extension's canReply(groupKey:data:) differs from the Dart canReply (iOS setup). |
| Permission dialog never appears (Android) | Check google-services.json is in android/app/ and the Google Services plugin is applied (Step 3). On Android below 13 there is no dialog by design. |
Android build fails after adding google-services.json |
The com.google.gms.google-services plugin must be declared and applied — see Step 3. |
NotInitializedException |
initAppId() was not called, or was called after the failing method. Call it in main() before runApp(). |
Foreground notification not shown in the tray, but addForegroundListener fires |
Expected. Call notification.showNotification() inside the listener (Step 8). |
| Notification appears with empty title/body | Android: the backend sets the FCM notification field — Android messages must be data-only. iOS: the alert (aps.alert) must carry the title and body. Ask the NotiBlast team. |
| Notifications are not grouped / image missing | The backend is not sending group_key / image_url in data, or the image URL requires authentication. |
Notifications still arrive after logout() |
Expected — logout() only unlinks the user. Also call optOut() to stop notifications. |
Notifications arrive again after optOut() |
A later login(), token change, or optIn() re-subscribed the device. The SDK does not persist opt-out — store it yourself (Opt-out / opt-in). |
Topic notifications still arrive after optOut() / logout() |
Expected — topics bypass the backend subscription. Call unsubscribeTopic() for each topic. |
subscribeTopic() throws TopicSubscriptionException on iOS |
The APNs token is not ready yet. Subscribe after login() and retry later. |
See CHANGELOG.md for release notes.