Feature SDKs (Free)
push_notification_sdk Free
- Tier
- Freeversion 1.3.6
- Command
archipelago generate push_notification_sdk- Generates
features/push_notification/push_notification_apifeatures/push_notification/push_notification_implfeatures/push_notification/push_notification_fcm_impl(if selected)
- Generates first
- shared_consentwhen needed and missing, so there is nothing to install first
- Depends on
- shared_identifiers, flutter_l10ngenerate these first
Push notification feature SDK with Firebase Cloud Messaging (FCM). Provides token lifecycle, foreground/tap streams, and topic subscriptions.
Generates a vendor-neutral PushNotificationSDK contract, an orchestrator that discovers provider handlers at runtime, an FCM-backed provider handler, and an on-device notification policy (tiers, quiet hours, caps and lock-screen-safe text).
OneSignal is a separate brick
This brick only ever generates the FCM provider. OneSignal support is a separate, Pro-tier brick — push_notification_onesignal (bricks/push_notification_onesignal/) — which patches this brick's generated push_notification_impl package to add a second provider handler. See that brick's own docs page for OneSignal setup. An earlier version of this page described includeOneSignal as a variable of this brick and a push_notification_onesignal_impl package as part of this brick's output — that was never true of the brick source and has been corrected here.
Variables
| Variable | Type | Default | Description |
|---|---|---|---|
| includeFcm | boolean | true | Include Firebase Cloud Messaging (FCM) provider package |
| uiKitPackageName | string | app_ui_kit | UI kit the settings page imports. pre_gen replaces it with the kit recorded in archipelago.yaml |
| prefix | string | App | UI kit class prefix, for example Acme for AcmeTheme. pre_gen reads it from archipelago.yaml (project.prefix), so you rarely set it |
| prefix | string | App | UI kit class prefix (AppAppBar). pre_gen replaces it with project.prefix from archipelago.yaml |
hooks/pre_gen.dart hard-fails generation if includeFcm is set to false:
includeFcm must be true. push_notification_sdk requires FCM. For OneSignal support, add the push_notification_onesignal brick (Pro).
In practice this means the variable exists as a guard rail rather than a real on/off toggle — every generation of this brick includes FCM.
Usage
Interactive
archipelago generate push_notification_sdkNon-interactive (CI)
archipelago generate push_notification_sdk --config my_config.jsonGenerated Structure
features/
└── push_notification/
├── push_notification_api/
│ └── lib/src/
│ ├── models/
│ │ ├── push_notification.dart
│ │ ├── push_notification_permission.dart
│ │ ├── push_notification_provider.dart
│ │ └── push_token.dart
│ ├── policy/notification_policy.dart # tiers, QuietHours, port, registry
│ ├── push_notification_provider_handler.dart
│ └── push_notification_sdk.dart
├── push_notification_impl/
│ └── lib/src/
│ ├── di/push_notification_global_module.dart
│ ├── policy/
│ │ ├── default_notification_policy.dart
│ │ └── notification_policy_store.dart
│ ├── presentation/notification_settings_page.dart
│ └── push_notification_sdk_impl.dart
└── push_notification_fcm_impl/
└── lib/src/
├── di/register_module.dart # FirebaseMessagingRegisterModule
└── fcm_push_notification_handler.dartpush_notification_api and push_notification_impl are always generated; push_notification_fcm_impl is generated because includeFcm is always true in practice (see above).
Public API
PushNotificationSDK (push_notification_api)
abstract interface class PushNotificationSDK implements FeatureSDK {
static const String settingsPath = '/notification-settings';
Set<PushNotificationProvider> get availableProviders;
Future<PushNotificationPermission> requestPermission();
Future<PushNotificationPermission> getPermissionStatus();
Future<PushToken?> getToken();
Future<void> subscribeToTopic(String topic);
Future<void> unsubscribeFromTopic(String topic);
Stream<PushNotification> get foregroundStream; // policy-approved only
Future<QuietHours?> getQuietHours();
Future<void> setQuietHours(QuietHours? quietHours); // null clears
bool get marketingEnabled; // ConsentPurpose.marketing
Future<void> setMarketingEnabled({required bool enabled});
Future<NotificationPreferences> notificationPreferences(); // for your backend
Stream<PushNotification> get tapStream;
Future<void> clearAll();
}final pushSDK = getIt<PushNotificationSDK>();
final permission = await pushSDK.requestPermission();
pushSDK.tapStream.listen((notification) {
if (notification.route != null) {
context.router.push(RouteUri.parse(notification.route!));
}
});PushNotificationProvider is enum { fcm, oneSignal } — the oneSignal member exists in the shared contract regardless of which provider brick is installed, since push_notification_api is provider-agnostic by design. PushNotificationPermission is enum { granted, denied, provisional, notDetermined }.
Provider handler contract
Each provider package (this brick's push_notification_fcm_impl, or the separate push_notification_onesignal_impl) implements PushNotificationProviderHandler and registers it with GetIt. The orchestrator (PushNotificationSDKImpl, in push_notification_impl) discovers every registered handler via getIt.getAll<PushNotificationProviderHandler>() at construction time (falling back to resolving each known provider key on its own, and logging, if getAll throws) and delegates to the first one for token/permission operations; foregroundStream/tapStream merge events from all handlers if more than one is registered (an atypical configuration this brick doesn't itself produce, but supports).
FCM handler behavior (FcmPushNotificationHandler)
- Registered as
@Named('fcm') @LazySingleton(as: PushNotificationProviderHandler), so it coexists with the OneSignal handler. getToken()returnsnull(never throws) ifFirebaseMessaging.getToken()fails or is empty.tapStreammergesFirebaseMessaging.onMessageOpenedApp(background tap) with a one-time check ofgetInitialMessage()(terminated-launch tap) into a broadcastStreamController.clearAll()callsFirebaseMessaging.deleteToken()but deliberately does not cancel the tap-stream subscription or close the controller — this handler is aLazySingletonand must keep working across a logout/login cycle.AuthorizationStatus.deniedPermanently(Android 13+) andAuthorizationStatus.deniedboth map toPushNotificationPermission.denied— there is no separate "needs settings" state yet (see CHANGELOG 1.2.3).routeon the resultingPushNotificationis read fromdata['route']in the FCM payload.FirebaseMessaging.onMessageis subscribed once. Each foreground message is evaluated by the notification policy once, and only shown messages reachforegroundStream. FCM posts no OS banner for foreground messages on Android, so the app displays them.
Notification policy
Since 1.3.0 every foreground message passes one policy before anything is displayed. The policy sits between the vendor's receive/display hook and the display. Vendors reach it through the NotificationPolicy port in push_notification_api (NotificationPolicyRegistry.instance.policy), so they never depend on push_notification_impl. PushNotificationGlobalModule installs DefaultNotificationPolicy before it builds the handlers.
Tiers
The sender sets data['tier']:
| Tier | Consent | Quiet hours | Cap (default) | Lock screen |
|---|---|---|---|---|
critical | ignored | ignored | never capped | generic text |
transactional | not needed | ignored | none | generic text |
marketing | needs ConsentPurpose.marketing | dropped inside the window | 3 per rolling 24 h | as sent |
untagged (missing or unknown tier) | not needed | ignored | never capped | as sent |
An untagged message behaves exactly as it did before the policy existed: it is shown unchanged, the vendor keeps its OS banner, and it never counts against a cap. Redaction is opt-in (since 1.3.1): a message gets generic lock-screen text only when the sender sets an explicit critical or transactional tier, or data['sensitive'] = 'true' (any tier). Upgrading therefore changes nothing until the sender tags messages.
Decision flow
flowchart TD
A[Foreground message from vendor hook] --> B{tier}
B -- untagged --> U[Show, never capped or recorded]
B -- critical --> S[Show]
B -- marketing --> C{marketing consent granted?}
C -- no --> X1[Drop: noConsent]
C -- yes --> Q{inside quiet hours?}
Q -- yes --> DF{"display installed and passes consent + cap?"}
DF -- no --> X2[Drop: quietHours]
DF -- yes --> X4["Defer to quiet-hours end (counts against cap)"]
Q -- no --> K{cap used up in window?}
B -- transactional --> K
K -- yes --> X3[Drop: capReached]
K -- no --> R[Record delivery] --> S
S --> L{"sensitive? (data.sensitive = true, or explicit critical / transactional)"}
U --> L
L -- "yes, with data.detail_*" --> SP[Show detail; sender text becomes publicTitle / publicBody, no vendor OS banner]
L -- yes --> P[Set publicTitle / publicBody, no vendor OS banner]
L -- no --> D[Vendor may show OS banner]
SP --> E
P --> E[Emit on foregroundStream]
D --> E
Quiet hours
final push = getIt<PushNotificationSDK>();
// 22:00-07:00 local time. The window may cross midnight.
await push.setQuietHours(
const QuietHours(startMinute: 22 * 60, endMinute: 7 * 60),
);
await push.setQuietHours(null); // clearMarketing messages inside the window are dropped by default. Add the local_notification_display brick to defer them instead: the policy hands each one to the DeferredNotificationDisplay port (NotificationPolicyRegistry.instance.deferredDisplay), which schedules it for the end of quiet hours in the device time zone. A message is deferred only if it would pass consent and the cap, and it counts against the cap when it is scheduled. The default display drops, so without that brick nothing changes.
Settings page
PushNotificationSDKImpl registers a settings page at PushNotificationSDK.settingsPath (/notification-settings); launch() opens it. The route is a PageInfo builder in registerRoutes, so there is no route codegen and no extra eager registration (the SDK is already an eager FeatureSDK). The page has:
- Quiet hours: on/off (on applies 22:00-07:00) and a start and end time picker, written through
setQuietHours. - Promotions: the marketing opt-out, written through
setMarketingEnabled. It recordsConsentPurpose.marketing, the consent the policy already checks, so a consent sheet and this switch stay in step. That purpose also covers attribution (AppsFlyer). - A note that critical and transactional messages are always sent: they have no opt-out.
Strings come from push_notification_impl/l10n/{en,id}.i18n.json (slang, PushLocale), in the locale locale_core has set. Home's Settings tile (home_sdk 2.0.0) opens the page when an installed feature declares /notification-settings in its pageConfigurations; without this brick the tile is hidden. To link it from elsewhere:
await context.router.root.pushPath('/notification-settings');
// or, with the push packages as a dependency:
await getIt<PushNotificationSDK>().launch(context);Preferences for your backend
Background and terminated messages never reach the device policy, so your backend applies the same rules before it sends (contract "Server engine v1" in the notification policy contract). It needs the user's time zone, quiet hours and marketing consent:
final prefs = await getIt<PushNotificationSDK>().notificationPreferences();
await api.post('/me/notification-preferences', body: prefs.toJson());
// {"v": 1, "tz": "Asia/Jakarta",
// "quiet_hours": {"start": "22:00", "end": "07:00"}, "marketing": true}tz is the device's IANA zone (flutter_timezone), quiet_hours is null when none are set, and marketing is ConsentPurpose.marketing. The record has no digest or region: your backend adds those from its own data. Send it with the push token and again whenever the user changes a setting. The zone is read only when you call this method, not at boot. The full backend setup is in Notification policy on your backend.
Lock-screen-safe text
Sensitive messages (explicit critical / transactional tier, or data['sensitive'] = 'true') come out with publicTitle / publicBody set to generic text. Untagged messages are never redacted, so tag every message that carries amounts or personal data. The vendor posts no OS banner for them; display them from foregroundStream and pass the public text as the lock-screen version (Android publicVersion, iOS: hide the preview). The local_notification_display brick does this for you.
A sender that follows the server contract splits a sensitive message itself: the visible title and body are the lock-screen-safe text, and the full text goes in data['detail_title'] / data['detail_body']. When the message is sensitive and either key is present, the policy shows the detail text in the app (title / body) and uses the sender's title and body as publicTitle / publicBody, instead of the generic text below. A missing detail key keeps the sender's text for that field; a missing sender title or body falls back to the generic text. Without detail keys nothing changes. A non-sensitive message (no sensitive: "true", and an untagged or marketing tier) with detail keys is shown unchanged: no split and no publicTitle.
Pass localized generic text to the policy:
NotificationPolicyRegistry.instance.install(
DefaultNotificationPolicy(
lockScreenTitle: t.push.lockScreenTitle,
lockScreenBody: t.push.lockScreenBody,
caps: {
NotificationTier.marketing: const NotificationCap(
max: 2,
window: Duration(days: 1),
),
},
),
);Testing
DefaultNotificationPolicy.decide(...) is a pure function of the message, now, quiet hours, recent deliveries, the cap and consent. The policy takes clock, store, isGranted and deferredDisplay, so tests inject all four. Caps count deliveries in a rolling window, stored in SharedPreferences, and evaluations run one at a time, so a burst cannot overshoot a cap.
Limits
- Background and terminated delivery: the OS shows these without running Dart, so the policy does not see them. Keep amounts and personal data out of the
title/bodyyou send, or send data-only messages and display them locally. - The policy does not run for taps (
tapStream).
DI wiring
PushNotificationGlobalModule (push_notification_impl) must be registered after every provider package's module, because its init() eagerly reads getIt.getAll<PushNotificationProviderHandler>(). It stays eager on purpose: building the handlers attaches the vendor message and tap listeners (and starts OneSignal) at boot, before the first push arrives.
@InjectableInit(
externalPackageModulesBefore: [
ExternalModule(PushNotificationFcmImplPackageModule), // provider(s) first
ExternalModule(PushNotificationGlobalModule), // orchestrator last
],
)post_gen.dart wires exactly this order into the app's injector.dart. The ordering itself is also declared declaratively in push_notification_impl/pubspec.yaml under an archipelago: di_modules: block (depends_on: [push_notification_fcm_impl]) — the separate push_notification_onesignal brick's post_gen.dart patches this same list to add push_notification_onesignal_impl when that brick is installed alongside this one, rather than this brick knowing about OneSignal directly.
Tap-driven navigation
getIt<PushNotificationSDK>().tapStream.listen((notification) {
if (notification.route != null) {
router.push(RouteUri.parse(notification.route!));
}
});FCM setup
Firebase must be configured with flutterfire configure: see Firebase Setup. The brick wires a guarded Firebase.initializeApp() into bootstrap.dart (before initializeInjector()). Without Firebase config the app still boots and this vendor is skipped with a debug message.
- Add
google-services.json(Android) toapps/<appName>/android/app/. - Add
GoogleService-Info.plist(iOS) toapps/<appName>/ios/Runner/. - No
FirebaseApp.configure()call is needed inAppDelegate.swift:bootstrap.dartinitializes Firebase from Dart. - Enable the Push Notifications capability in Xcode (Runner target > Signing & Capabilities).
- Enable Background Modes > Remote notifications.
- Android: no extra steps — FCM is configured via
google-services.json.
The FCM token is available via getIt<PushNotificationSDK>().getToken() after requestPermission() is granted.
Android notification channel (API 26+)
Not generated by this brick — set up manually if your app needs a named channel:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"default", "General", NotificationManager.IMPORTANCE_DEFAULT,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}ANR-prevention trampoline
The post_gen hook writes NotificationActivity.kt next to MainActivity.kt (the package path comes from archipelago.yaml) and declares it in android/app/src/main/AndroidManifest.xml. The activity receives a cold-start notification-tap intent, forwards the route to MainActivity through the deeplink EventChannel, then finishes itself at once. It avoids an ANR dialog on slow devices when a tap intent routes through MainActivity.onCreate directly.
The hook skips a class or manifest entry that already exists. When MainActivity.kt is not at the package path, it warns and writes nothing.
MainActivity from flutter_modular_monorepo already forwards ACTION_VIEW intents to the deeplink channels (see Deeplink handling).
Configure FCM to target it with a click_action in the data payload:
{
"to": "<FCM_TOKEN>",
"data": {
"click_action": "com.example.yourapp.NOTIFICATION_TAP",
"route": "/destination"
}
}Dependencies
feature_sdk,router_registry,identifiers(shared packages) —PushNotificationSDKself-registers withFeatureSDKRegistryconsent_api(marketing consent) andshared_preferences(quiet hours and cap history), inpush_notification_impl.pre_gengeneratesshared/consent_apithroughshared_consentwhen the project lacks it- The UI kit,
locale_core,slangandslang_flutter(settings page), inpush_notification_impl firebase_messaging(push_notification_fcm_implonly)- Requires Firebase project setup (see FCM setup above) — same
google-services.json/GoogleService-Info.plistfiles asfirebase_analytics_impl
Changelog
Run archipelago brick info push_notification_sdk to see the latest published version. Enterprise customers can read the full CHANGELOG.md in the brick source repository.