Skip to main content

Notifications and push messages

Notifications and push messages are two roles of SMF, each with a module that provides it:

PartRoleModuleWhat it brings
NotificationsNotificationslocal_notificationsappNotifications: the permission to show notifications, a notification now or at about a time to come, and the taps on them.
Push messagesPush messagesfirebase_messagingappPush: the address of the device for the backend of the app, the messages that the backend sends there, and the taps on them.

The push role requires the notifications role, since a message in the foreground shows as a notification of the app, and the permission to show messages is that of the notifications. So firebase_messaging brings local_notifications with it while that module is the only one that provides the notifications:

smf create my_app -m home,onboarding,settings,bottom_tabs,firebase_messaging

An app can also have the notifications without push messages, with -m local_notifications.

FileFromWhat it holds
lib/core/notifications/app_notifications.dartthe notifications roleappNotifications, through which code shows notifications and asks for the permission, NotificationPermission, notificationOwnerKey and initNotifications(). It exports AppNotification and NotificationTap, so the code of the app imports this file alone.
lib/core/notifications/notifications_service.dartthe notifications roleNotificationsService, the interface that the module that provides the role implements, AppNotification, NotificationTap, and the channel and the icon of the notifications. The code of the app does not use it.
lib/core/notifications/notifications_setting.dartthe notifications role, in an app with a settings screenNotificationsSetting, the entry of the settings screen.
lib/core/notifications/notifications_step.dartthe notifications role, in an app with an onboardingThe functions of the step of the onboarding that asks for the permission.
android/app/src/main/res/drawable/ic_notification.xml, android/app/src/main/res/raw/notifications_keep.xmlthe notifications roleThe small icon of a notification on Android, and the file that keeps it in a release build.
lib/core/push/app_push.dartthe push roleappPush, through which code reads the address, the messages and the taps, and initPush(). It exports PushMessage.
lib/core/push/push_service.dartthe push rolePushService, the interface that the module that provides the role implements, and PushMessage. The code of the app does not use it.

bootstrap() starts both in its platform phase, after the preferences, so the app knows its permission and the tap that launched it before its first frame:

lib/bootstrap.dart
Future<void> bootstrap() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await initPreferences();
await initNotifications();
await initPush();
}

Neither role registers anything in a DI container. Code of the app uses appNotifications and appPush, two top-level variables, and calls neither initNotifications() nor initPush(), which only bootstrap() calls.

Show and schedule a notification​

import 'core/notifications/app_notifications.dart';

await appNotifications.show(
const AppNotification(
id: 1,
title: 'Your order is on its way',
body: 'It arrives today.',
data: {'order': '42'},
),
);

await appNotifications.schedule(
const AppNotification(id: 2, title: 'Time for a break'),
at: DateTime.now().add(const Duration(hours: 1)),
);
CallWhat it does
appNotifications.show(notification)Shows the notification now.
appNotifications.schedule(notification, at: time)Shows the notification at about that time, not to the minute, also when the app is closed then and after the device restarts. The time must be in the future.
appNotifications.cancel(id)Removes the notification of the id, shown or scheduled.
appNotifications.cancelAll()Removes every notification of the app, shown or scheduled.
appNotifications.scheduled()Lists the notifications that are scheduled and not shown yet.

A notification is an AppNotification with an id, a title, and an optional body and data. The id is a 32-bit integer from 0 up, which the platforms keep, and a notification with the id of one that is shown or scheduled takes its place: showing a notification cancels the scheduled one of its id. show() and schedule() fail with an ArgumentError for an id that the app does not take, and schedule() for a time that is not in the future. Both complete whether the app may show notifications or not, so ask for the permission first where it matters.

Every notification goes to one channel of the app on Android, whose name in the settings of the system is the name of the app. A notification shows also while the app is in the foreground. On Android it has ic_notification.xml as its small icon: replace the bell there with the icon of the app, a shape on a transparent background, and keep the name of the file, since notifications_keep.xml keeps the icon in a release build by that name.

The permission​

The app needs the permission of the user to show notifications. appNotifications.permission is a ValueListenable of a NotificationPermission:

ValueWhat it means
grantedThe user allowed it.
deniedThe app asked, and the user did not allow it or turned the notifications of the app off later.
notAskedThe app may not show notifications, and has not asked yet.

appNotifications.request() asks the user, unless the app may show notifications already, and completes with the permission once the user answered, also with a no. On Android 13 and later an app that may not show notifications looks the same whether it asked or not, so the app remembers that it asked in the preferences, under notifications.asked. That is why the notifications role requires the preferences role.

  • Where the system has no question to show, as on Android before 13, the request opens the settings of the system for the notifications of the app instead. So one request is enough on every platform.
  • After denied, the system may not ask again: iOS asks only once, and Android stops after a second no. appNotifications.openSettings() opens the settings of the system, where the user turns the notifications on.
  • A request while another is on its way completes with that one, since Android shows one question at a time.
  • The app asks the system again each time the user comes back to it, so the permission follows a change in the settings of the system.

The app asks nothing when it starts. Where it asks depends on the other roles of the app:

The app hasWhere it asks
An onboarding, such as onboardingOn a page of its own in the onboarding, "Turn on notifications", with the buttons Allow notifications and Not now. The onboarding shows the page only while the permission is notAsked, and a user who skips the onboarding does not see it.
A settings screen, such as settingsThe entry Notifications shows On or Off. A tap asks while the app has not asked, and opens the settings of the system after that.
NeitherNowhere yet. Call appNotifications.request() where the user turns on something that notifies, so that the question of the system explains itself.

An app with both has both. The texts of the step and of the entry are in English and in Ukrainian, and follow the language of an app with a module that provides the localization.

The step of the notifications in the onboarding: a dark green cell with a bell, the title Turn on notifications, the text The app tells you when something needs your attention, even when it is closed., the button Allow notifications, and Not now below it.The settings screen in the light theme, with the entry Theme, the row Language and the row Notifications with the value Off.

Since a user may skip the onboarding or say Not now, code that needs notifications checks appNotifications.permission and calls request() itself.

Taps​

A tap on a notification reaches appNotifications.taps, a stream of NotificationTaps, each with the id and the data of its notification. appNotifications.launchTap is the tap that launched the app, known once bootstrap() is over. Read both once the first screen is shown, and navigate as the rest of the app does:

final tap = appNotifications.launchTap;
if (tap != null) openOrder(tap.data['order']);
appNotifications.taps.listen((tap) => openOrder(tap.data['order']));

A notification runs no code of the app when it shows. An error of the provider when the user comes back to the app, or one that its taps send, has no caller, so it reaches the handlers of the uncaught errors of the app, and the notifications go on.

Notifications with an owner​

A notification may have an owner: code that shows it and handles its taps itself, such as the push role, which shows the messages that arrive in the foreground. Such a notification names its owner under notificationOwnerKey in its data and has a negative id, so that it never takes the place of a notification of the app. show() and schedule() refuse a negative id without an owner, and an owner with an id from 0 up.

appNotifications.taps and launchTap leave out the taps on a notification with an owner. appNotifications.tapsOf(owner) and launchTapOf(owner) give them to their owner, so the code of the app sees each tap once. scheduled() and cancelAll() take the notifications of an owner too.

Push messages​

A backend sends push messages to the address of a device, through the provider of the push role. With firebase_messaging the address is the registration token of Firebase Cloud Messaging, and the backend sends through Firebase; see Cloud Messaging. Firebase sends to iOS only once you have uploaded the APNs key of your team to the Firebase project, a step by hand whose click-by-click guide also shows how to sign the app with your paid team.

The address​

appPush.address is a ValueListenable<String?> of the address of this device. It is null until the platform gives one, and the app works without it. The app asks for it when it starts, without waiting for it, so the first frame never waits for the network, and again each time the user comes back to the app while it has none. Listen to it and send each new address to the backend: it may come late, and it may change.

import 'core/push/app_push.dart';

void sendAddressToBackend() {
final address = appPush.address.value;
if (address != null) backend.registerDevice(address);
appPush.address.addListener(() {
final address = appPush.address.value;
if (address != null) backend.registerDevice(address);
});
}

In debug mode the app prints each new address, so you can send this device a message by hand:

The address of this device for push messages: <the address>

On iOS the address needs a token of the Apple Push Notification service (APNs), which iOS gives only to a build signed with the entitlement aps-environment. The push role adds it to ios/Runner/Runner.entitlements with the value development, and the background mode remote-notification to Info.plist.

  • The iOS simulator on a Mac gets a token. One on a macOS virtual machine, such as a hosted CI runner, never does.
  • On an iPhone, signing with the entitlement needs a team of the paid Apple Developer Program, which is a step by hand of the app; see the click-by-click guide. With a free personal team, Xcode cannot sign the app. To run it on an iPhone then, remove the key aps-environment from ios/Runner/Runner.entitlements, and the app runs there without an address. The simulator needs no change.
  • The value development suits the builds that you run from Xcode. When Xcode exports the app for distribution, it takes the value of the provisioning profile.

Messages​

A message is a PushMessage with data, and with an id, a title and a body when it has them. A value of its data that the platform gives as something other than a string is its JSON.

The app isA message with a title or a bodyA message with data alone
In the foregroundReaches appPush.messages, and shows as a notification. Where the system does not show it, the app shows it as a notification of its own through appNotifications, owned by the push role.Reaches appPush.messages.
In the background, or closedThe system shows it, on Android in the channel of the notifications of the app.Runs no code of the app.

Either way the user has to allow the notifications of the app for a message to show. The push role asks for no permission itself: appNotifications.request() asks, as above. Images in a message do not show on iOS, which needs an extension of the app for them.

Taps on messages​

appPush.taps tells of each tap on a message while the app runs, whether the system or the app showed it. appPush.launchMessage is the message whose tap launched the app, known once bootstrap() is over. Read both once the first screen is shown, and navigate as the rest of the app does. A tap on a message does not reach appNotifications.taps, so the code of the app sees each tap once.

For a provider​

A module that provides one of these roles implements its interface, NotificationsService or PushService, and contributes the implementation as data of the role, as the providers of other service roles do; see Provide a role. The doc comments of the two interfaces in the app hold their contracts. In short:

  • The function that creates the service asks the user nothing, and may be called again. The service that it returns then has what the device kept, such as the scheduled notifications or the address.
  • A provider of notifications shows every notification in the channel notificationChannelId with a high importance and, on Android, the small icon notificationIcon, also in the foreground. schedule shows a notification at about its time, also after a restart of the device, and needs no permission for exact alarms. show cancels the scheduled notification of its id. request completes once the user answered, and opens the settings of the system where the platform has no question to show.
  • A provider of push messages never asks for the permission to show notifications, and never keeps the notifications of the app from showing in the foreground. Its address and launchMessage never throw, and the address stays the same from one launch to the next unless the platform gives the device another, which addressChanges tells of. platformShowsInForeground says whether the platform shows a message in the foreground. Where it does not, the service shows nothing of the message, and the app shows it.

The structural rules notifications.factory_calls and push.factory_calls check that only the files of the implementation import the file of the interface, and notifications.start_calls and push.start_calls that only bootstrap() starts the roles. Only a running app shows whether a provider keeps to the rest. SMF's CI checks both roles in running apps with a fixture provider of each, and with a broken one that each test must fail on; see Contributing.

The README and the guide for coding agents​

The notifications role writes the section "Notifications" of the README of the app: how the app shows notifications, the permission and where the app asks for it, and the small icon of Android. The push role writes "Push messages": the address and what it needs on iOS, how a message shows, and the taps. Each provider adds a section of its own, such as how to send a test message.

The guide for coding agents of the app has the sections "Notifications" and "Push messages", with a note of the role and one of the provider in each.

In tests​

A test of an app with these roles runs bootstrap(), which starts the service of each provider. The packages of the providers have no platform side for tests, so a test answers their channels itself; see local_notifications and firebase_messaging.

A test thatDoes this
scripts what a provider answersIt gives appNotifications.start() or appPush.start() a service of its own, a class of the test that implements NotificationsService or PushService.
checks what the next launch findsIt runs initNotifications() or initPush() again, which creates the service anew with what the device kept. The address is then null until the new service gives one.
brings the user back to the appIt may go through any state of the lifecycle: both roles look only at AppLifecycleState.resumed.

A call of appNotifications made before the first initNotifications() waits for it. The mocks of the modules start as on a first launch: the app may not show notifications and has not asked, so the onboarding shows its step. A request grants the permission, so a test that asks goes on with an app that may show notifications.