notiblast_sdk 0.9.0 copy "notiblast_sdk: ^0.9.0" to clipboard
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.

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 #

  1. NotiBlast.initAppId(appId) — called once in main(). Prepares Firebase and stores your App ID. It does not register the device or show notifications yet.
  2. NotiBlast.Notifications.requestPermission() — asks the OS for permission. It does not contact the backend.
  3. Listeners — you register click / foreground / error callbacks.
  4. 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.
  5. 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: minSdk 21 or higher.
    • iOS: deployment target 15.0 or higher.
  • 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 #

  1. Open the Firebase Console and create a new project (or pick an existing one dedicated to this app).
  2. Click Add app and register each platform you target:
    • Android — enter the exact applicationId from android/app/build.gradle.kts (look for applicationId = "...").
    • iOS — enter the exact Bundle Identifier from Xcode (target Runner → General → Bundle Identifier).
  3. Download the config file Firebase gives you for each platform:
    • Android: google-services.json
    • iOS: GoogleService-Info.plist

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 #

  1. Open ios/Runner.xcworkspace in Xcode (not Runner.xcodeproj).
  2. Drag GoogleService-Info.plist into the Runner folder in the Project Navigator.
  3. 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 #

  1. Declare the Google Services Gradle plugin in android/settings.gradle.kts, inside the plugins { ... } block:

    plugins {
        // ...existing plugins
        id("com.google.gms.google-services") version "4.4.2" apply false
    }
    

    Older Flutter projects without a plugins block in settings.gradle(.kts) declare it in the project-level android/build.gradle(.kts) instead.

  2. Apply the plugin in android/app/build.gradle.kts:

    plugins {
        // ...existing plugins
        id("com.google.gms.google-services")
    }
    
  3. Check minSdk in android/app/build.gradle.kts is at least 21. 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 #

  1. Set the iOS platform version in ios/Podfile:

    platform :ios, '15.0'
    
  2. Enable capabilities — in Xcode, select target Runner → Signing & Capabilities → + Capability, then add:

    • Push Notifications
    • Background Modes, with Remote notifications checked
  3. Upload an APNs key to Firebase — without this, no push ever reaches an iOS device:

    1. In the Apple Developer Portal, go to Certificates, Identifiers & Profiles → Keys and create a key with Apple Push Notifications service (APNs) enabled.
    2. Download the .p8 file. It can only be downloaded once — store it safely.
    3. In the Firebase Console, open Project Settings → Cloud Messaging → Apple app configuration, and upload the .p8 file together with your Key ID and Team ID.
  4. Set the notification center delegate in ios/Runner/AppDelegate.swift, inside application(_:didFinishLaunchingWithOptions:) and before the return:

    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)
      }
    }
    

    FlutterAppDelegate forwards notification callbacks to every registered plugin, so both firebase_messaging and flutter_local_notifications (used by this SDK) receive them.

    • Without this line, notifications still appear, but addClickListener is never called when a notification is tapped.
    • Do not use FLTFirebaseMessagingPlugin.configureNotificationCenterDelegate(). It makes firebase_messaging claim the delegate for itself, and taps never reach the SDK.
    • Projects using the UIScene lifecycle (newer Xcode templates with FlutterImplicitEngineDelegate): keep this line in didFinishLaunchingWithOptions as shown — do not move it into didInitializeImplicitFlutterEngine.
  5. 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).

    1. Open ios/Runner.xcworkspace (not .xcodeproj). Choose File → New → Target → Notification Service Extension, name it NotificationService, pick the same Team as Runner, and click Finish. When Xcode asks to activate the scheme, choose Cancel.

    2. 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 devices
      Product Bundle Identifier Runner's bundle ID for that configuration + .NotificationService
      Marketing 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 fails

      Using 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.app for prod and com.example.app.staging for staging, set the extension to com.example.app.NotificationService and com.example.app.staging.NotificationService respectively — otherwise the staging build fails to install.

      The extension needs no capabilities (no Push Notifications, no App Groups) — Push Notifications stays on Runner only.

    3. 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.

    4. Replace the generated NotificationService/NotificationService.swift with:

      import NotiBlastNSE
      
      class NotificationService: NotiBlastNotificationService {}
      
    5. Add the extension target to ios/Podfile, outside target 'Runner':

      target 'NotificationService' do
        use_frameworks!
        pod 'NotiBlastNSE', :path => '.symlinks/plugins/notiblast_sdk/ios'
      end
      
    6. Run flutter pub get, then cd ios && pod install.

    7. 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.

    NotiBlastNSE has no Flutter dependency, so it is safe inside an extension. Image URLs must be https — iOS blocks plain http in extensions, and the notification is then shown without the image.

    Using notification replies? See Replying from a notification for the extra AppDelegate line and the optional canReply override in this extension. Chat app? See Chat notifications on iOS for the optional communication override.

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 true immediately 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() runs What login() does
    Granted Registers the device with your user ID
    Denied Reports the denial to the backend
    Never asked Sends nothing

Order matters: call requestPermission() before login() on every cold start. Once granted, the dialog does not show again, so this is safe. If permission is granted only after login() (for example, you delay the dialog until onboarding ends), the device registers on the next login() — 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 — call notification.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. Before login() 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 to addRegistrationErrorListener.
  • 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 skips login() 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() throws DeviceRegistrationException after 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 DeviceRegistrationException if the backend call fails, and NotInitializedException if called before initAppId().
  • optIn() does nothing (and does not throw) if permission is not granted or no FCM token exists yet. The device then registers on the next login().

The SDK does not persist the opt-out state. Any later registration — login(), an FCM token change, or optIn() — subscribes the device again. If your user opts out, store that preference yourself and call optOut() again after login() 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 before initAppId() throws NotInitializedException.
  • 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 a TopicSubscriptionException.
  • iOS: subscribing fails if the APNs token is not ready yet (common right after requestPermission()). Subscribe after login() and retry on failure.

Topics are independent of opt-out and logout. Topic pushes are not blocked by optOut(), and topics are not left on logout() — call unsubscribeTopic() 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 ArgumentError if the group key is empty, and NotificationManagementException if 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_key are stacked into one group (Android) or thread (iOS). On Android the SDK also posts one silent summary per group_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_url is 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. canReply reads the data your backend already sends.
  • All functions need @pragma('vm:entry-point') (handler, canReply, and dismissOnReply). 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 canReply whenever 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 canReply synchronous, fast, and pure — only read the notification. If it throws, the notification is still shown, just without the reply action.
  • Call setReplyHandler() after initAppId() on every app start, at the top of your main() flow and before login(). 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 once setReplyHandler() 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 BadDeviceToken until the app is opened. So in that state the SDK defers every token operation — device registration from login(), token refresh, logout(), optIn() / optOut(), topic (un)subscribe and Notifications.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 dismissOnReply removes 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 true removes 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.

  1. Call NotiblastSdkPlugin.setupReply() in ios/Runner/AppDelegate.swift, inside application(_:didFinishLaunchingWithOptions:) and before the return:

    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.

  2. 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
      }
    }
    

    data has the same shape as NotiBlastNotification.data in Dart (FCM keys removed, JSON string values decoded). Keep this logic identical to your Dart canReply — 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: 1 is already required for the extension. If the server does send the notiblast_reply category, the extension removes it when canReply returns false.
  • 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 / canReply would 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. toConversation reads the data your backend already sends. It runs in a background isolate when the app is closed, so the same rules as canReply apply: @pragma('vm:entry-point'), synchronous, no app state. If it throws or returns an empty conversationId, senderId, senderName, or text, the notification is shown as a regular one.
  • Field by field style. A null field in a conversation's style uses the default style, and a null field 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 its canReply as usual. A reply your handler accepted without throwing shows up as a message from selfName; a failed one is not added.
  • Avatars are downloaded once per sender and cached; if a download fails, the cached avatar or defaultAvatarDrawable is 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. text is 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 own FileProvider. Image files are kept in the app's cache for 7 days, and clearConversation() 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 with conversationAvatarUrl as its large icon and a small app icon badge, collapsed and expanded. You choose the image — the SDK never falls back to senderAvatarUrl. It is downloaded once per URL and cached; if it is null or fails, defaultAvatarDrawable is used, then a generated avatar with the initials (first letters of the first two words, e.g. "Team Cicle" → "TC") of avatarName, else the conversation title, on a colored background. Set avatarName when 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.
  • defaultAvatarDrawable is 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() after initAppId() on every app start, before login(). 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:

  1. Target Runner → Signing & Capabilities → + Capability → Communication Notifications (adds com.apple.developer.usernotifications.communication to the entitlements). Enable the same capability for the App ID in the Apple Developer Portal and regenerate the provisioning profiles.

  2. ios/Runner/Info.plist:

    <key>NSUserActivityTypes</key>
    <array>
      <string>INSendMessageIntent</string>
    </array>
    
  3. ios/NotificationService/Info.plist, inside the existing NSExtension dictionary:

    <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. communication decides the look of pushes shown by iOS (app in the background or closed); toConversation only applies to Android.
  • Required fields. An empty conversationId, senderId, or senderName shows the push as a regular notification.
  • Avatars. avatarURL is downloaded (5 s timeout, https only) alongside image_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 is nil or fails, the SDK draws an initials avatar — first letters of the first two words ("Team Cicle" → "TC") of avatarName, else the group name (group) or sender name (1-to-1), on a colored circle — since iOS would show the app icon. Pass initialsFallback: false to keep the app icon instead, e.g. when the avatar is a company logo rather than a person. A group without conversationTitle uses 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; addForegroundListener is still called, but showNotification() does nothing for them (a local copy would be a duplicate without the look). The NSE marks them with data['notiblast_communication'] == 'true'. Other pushes behave as before.
  • Reply, grouping, and images still work. The Reply action, group_key thread, and image_url attachment 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(); pass null to 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.json and/or GoogleService-Info.plist come from your app's own Firebase project (not a sample or dummy file).
  • ❌ The APNs key is uploaded to Firebase (iOS only).
  • ❌ AppDelegate.swift sets UNUserNotificationCenter.current().delegate = self (iOS only).
  • ❌ The NotificationService extension target exists and uses NotiBlastNSE (iOS only) — a test push with an image_url shows the image.
  • ❌ Using replies: AppDelegate.swift calls NotiblastSdkPlugin.setupReply(), and the extension's canReply(groupKey:data:) matches your Dart canReply (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.

0
likes
160
points
95
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter push-notification SDK for the NotiBlast backend: FCM device registration, tray display, inline reply, and chat-style conversation notifications.

Homepage
Repository (GitLab)

License

MIT (license)

Dependencies

device_info_plus, firebase_core, firebase_messaging, flutter, flutter_local_notifications, http, meta, package_info_plus, shared_preferences

More

Packages that depend on notiblast_sdk

Packages that implement notiblast_sdk