Configuration
Every notifications.* config key the package reads, what each one defaults to, and the behaviour behind it.
On this page
Overview#
All configuration lives in one Dart map returned by notificationConfig, by default in lib/config/notifications.dart. Magic's config repository merges it at boot, so Config.get(key) resolves a value anywhere in the app.
A file generated by an earlier version also contains a mail block. The package has no mail channel and no runtime code reads it; only notifications:channels prints its value. Delete it.
Full Config Map Reference#
import 'package:magic/magic.dart';
Map<String, dynamic> get notificationConfig => {
'notifications': {
'push': {
'driver': 'onesignal',
'app_id': env('ONESIGNAL_APP_ID'),
// 'safari_web_id': 'web.onesignal.auto.xxx', // legacy Safari only
'service_worker_path': 'OneSignalSDKWorker.js',
'service_worker_scope': '/onesignal/',
'notify_button_enabled': false,
'self_test_enabled': false,
'auto_request_on_login': false,
'reprompt_after_hours': 0,
'fallback_to_settings': true,
'share_user_attributes': false,
},
'push_state': {
'report_path': null, // e.g. '/devices/push-state'
'release_path': null, // e.g. '/devices/push-state/release'
'external_id_prefix': null, // 'user_' when absent
},
'database': {
'enabled': true,
'polling_interval': 30,
},
'soft_prompt': {
'enabled': true,
},
},
};
The push_state keys are covered in Telling your backend whether the device can be paged and Laravel Backend Setup.
notifications.push#
Controls the push channel and its driver.
| Key | Type | Default | Description |
|---|---|---|---|
driver |
String |
none | Name of the push driver. 'onesignal' is the only built-in one; an absent key means no push is configured and everything else keeps working. Any other value is logged at error level and push stays off, unless a driver of that name was registered with Notify.extend. |
app_id |
String |
none | OneSignal App ID (UUID). Push initialises only when it is non-empty. |
safari_web_id |
String? |
none | Legacy Safari Web Push ID. Modern Safari subscribes over VAPID and needs none. |
service_worker_path |
String? |
none | Web only. Path of the OneSignal service worker. Written as OneSignalSDKWorker.js by the installer; an absent key sends nothing, so OneSignal uses its own default. |
service_worker_scope |
String? |
none | Web only. Scope OneSignal registers its worker under. Written as /onesignal/ by the installer. A Flutter web build already owns the root scope with flutter_service_worker.js, and whichever registration lands second wins that scope, so OneSignal needs a scope of its own. |
notify_button_enabled |
bool |
false |
Web only. Show the floating OneSignal notify button. |
auto_request_on_login |
bool |
false |
Whether the package may raise the OS permission prompt by itself, once, after an identity is declared with Notify.initializePush(userId). See Asking for permission. |
reprompt_after_hours |
int |
0 |
Hours before the app's own reminder may return to somebody who turned it down. 0 and an absent key mean never. Read by NotificationManager.pushPromptAdvice(). |
fallback_to_settings |
bool |
true |
Mobile only. Whether a denied device keeps a route back: the reminder's open-settings button (Notify.openPushSettings()) and the SDK's settings fallback on requestPermission(). Inert on web. |
share_user_attributes |
bool |
false |
Whether the email and tags described with Notify.describePushUserUsing are sent to OneSignal at all. See Describing the user. |
self_test_enabled |
bool |
false |
Gates PushChannel, the client-triggered send that asks the backend to push a test notification to the caller's own devices. While it is off, PushChannel.isAvailable is false and the manager skips the channel. The backend carries the matching magic-starter.onesignal.self_test_enabled switch, also off by default, and answers 501 while it is off: set both or neither. |
Asking for permission#
There are two separate cadences here, and conflating them is the mistake this section exists to prevent.
The OS prompt is a one-shot. Once a person has answered it, no code can raise it again: on the web Notification.requestPermission() against a denied origin resolves immediately with denied and shows nothing, and iOS and Android never show their system dialog again either. What a request does show there is OneSignal's own English dialog offering the app's settings page, while fallback_to_settings is on (the default); with it off, a request on a denied phone shows nothing. The only route back is the browser's site settings or the OS Settings app. So an automatic request is worth exactly one shot, on a device that has never been asked.
The app's own reminder is not a one-shot. It is your UI, and it may appear on whatever cadence you configure, including on a device whose OS permission is denied: on mobile its button opens the app's own notification settings page directly (see fallback_to_settings), which is a real route back.
The automatic request#
With auto_request_on_login: true, the package raises the platform request once, from want() (which Notify.initializePush(userId) calls), and only when:
- the OS has never asked on this device (
PushDriver.canRaisePermissionRequest()), - the device is not already subscribed (its reachability is
off), - and it has not already been raised in this launch.
It is never raised from reconcilePushIdentity(), which runs on every auth-state change and on a signed-out boot: a dialog from there arrives with nothing in front of it explaining what it is for.
The reminder, and what its button can do#
Ask the package rather than deriving it:
import 'package:flutter/widgets.dart';
import 'package:magic_notifications/magic_notifications.dart';
// `MyOwnStorage` and the three `My...Row` widgets are the host app's own.
Future<Widget> pushPromptRow(MyOwnStorage myOwnStorage) async {
final advice = await Notify.manager.pushPromptAdvice(
declinedAt: await myOwnStorage.readPushPromptDeclinedAt(),
);
if (!advice.show) return const SizedBox.shrink();
return switch (advice.action) {
// A real dialog will appear: Notify.requestPushPermission().
PushPromptAction.request => MyEnableRow(),
// The prompt is spent, but this platform can open the app's own
// notification settings page: Notify.openPushSettings().
PushPromptAction.openSettings => MyOpenSettingsRow(),
// Nowhere to send the tap. Say where the switch lives instead.
PushPromptAction.instructions => MyBlockedInstructionsRow(),
PushPromptAction.none => const SizedBox.shrink(),
};
}
advice.show already accounts for reachability(), reprompt_after_hours, soft_prompt.enabled and the timestamp you passed in. advice.reachability is the same four-state read, carried so a status row does not need a second platform call. The package ships this row as PushPromptHost; see Push Prompt Component.
The decline timestamp stays yours. A decline is your UI's event, recorded wherever you already keep device state; a copy inside this package would be a second answer to drift out of sync with the first.
Describing the user#
OneSignal segments and personalises on what it knows about a person: an email subscription, and tags. This package owns the transport and the identity lifecycle; your app owns the values, because "email, first name, last name" is one product's answer and the next app has different fields or is not permitted to send an address at all.
Register the description once, at boot, next to wherever you register the driver:
import 'package:magic/magic.dart';
import 'package:magic_notifications/magic_notifications.dart';
// `User` is the host app's own model.
void describePushUser() {
Notify.describePushUserUsing((String externalId) {
final user = Auth.user<User>();
if (user == null) return null; // nothing to describe for this identity
return PushUserAttributes(
email: user.email,
tags: <String, String>{
'first_name': user.firstName,
'last_name': user.lastName,
},
);
});
}
User is your own model. externalId is the string you passed to Notify.initializePush(), so an app that keeps its user state elsewhere can look the right person up. The resolver is called on every login and every account switch, so nothing re-registers per login. Returning null says there is nothing to describe, which is the answer for a guest or for somebody who has not consented.
Nothing is sent until share_user_attributes is true. An app that registers no description sends nothing either.
What an account switch does#
Everything this package wrote is removed before the login or logout that moves the device on, while the SDK still points at the person it was written for. The SDK's own migration guide is the reason (onesignal_flutter, MIGRATION_GUIDE.md): a login to an id that does not exist yet creates the user and applies the operations already performed under the device-scoped user to it, and a logout leaves exactly such a device-scoped user behind. A removal issued after the switch therefore follows the next person onto their record, and on a shared device that is the whole problem.
The cost is that the previous person's tags come off their OneSignal record when they leave this device, and the resolver puts them straight back on their next login anywhere. The alternative is leaving a name and an email address attached to a subscription that has moved to somebody else.
Only what this package wrote is ever removed. A tag set from the OneSignal dashboard, from your backend, or from another client is not this device's to delete.
Two things to get right#
A tag written from a client is user-tamperable. Anybody holding the app can call the SDK from a browser console and write whatever they like under any key. That is fine for choosing an audience and fine for personalising a message. It is not fine for anything a backend later trusts: a plan tier, an entitlement, a role, a quota. Those belong in a server-side tag write over OneSignal's REST API, from the system that already owns the fact. This package ships no such path.
An email address and a name are personal data going to a third party. That is what share_user_attributes gates, and why it ships off. It gates the whole seam rather than the email alone, because this package cannot tell one tag from another: {'first_name': 'Ada'} is as personal as an address and {'plan': 'pro'} is not, and both arrive here as two strings.
Platform parity#
Both OneSignal drivers implement the same four calls (setTags, removeTags, addEmail, removeEmail) against the same v16 user model, so a description behaves identically on mobile and on the web. The one difference is a browser's to make: the web SDK is whatever script your web/index.html loads, so the driver checks that User.addEmail exists before it uses it and reports through the log when it does not, instead of accepting an address that went nowhere.
A custom PushDriver inherits removeTags (a loop over removeTag) and an addEmail that sends nothing and says so in the log.
notifications.database#
Controls polling for in-app notifications.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Written by the installer and shown by notifications:channels. The runtime does not read it: DatabaseChannel is always registered and always available. |
polling_interval |
int |
30 |
Seconds between GET /notifications requests. Supported range: 5 to 600. |
Change the interval with:
dart run magic:dowel notifications:configure --polling-interval 60
The runtime reads this key when it builds the poller, which is the first startPolling() and any startPolling() after a stopPolling(), including the automatic handoff when a realtime socket drops (that path stops the poller first). A change therefore takes effect on the next start.
It does not take effect across pausePolling() and resumePolling(): those keep the same poller instance, so it keeps the interval it was constructed with. Call stopPolling() and then startPolling() to apply a new value to a poller that is already running.
A value the reader cannot use is logged rather than thrown, because this is a timer wired to your auth state and a mistyped number must not take notification delivery down:
| Configured | Used | Why |
|---|---|---|
| absent | 30 | the default |
below 5, including 0 and negatives |
5 | Timer.periodic accepts 0 and then fires on every event-loop turn |
| above 600 | 600 | the supported range, enforced |
not an int ('30', 30.0) |
30 | the value is type-checked rather than cast, so it reads as absent |
notifications.soft_prompt#
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool |
true |
Whether your own soft prompt should show before Notify.requestPushPermission(). NotificationManager.pushPromptAdvice() honours it (false answers show: false), so a call site using that API does not check it a second time. |
The installer writes title and message keys beside enabled as copy suggestions for a prompt you build yourself. The package never reads them; PushPrompt takes its strings from translation keys (see Translation Keys).
A call site that needs the raw device state reads reachability() on the resolved driver (see Drivers); one deciding whether to interrupt somebody asks pushPromptAdvice() instead, which folds this key, the re-prompt interval and the platform's route back into one answer.
import 'package:magic_notifications/magic_notifications.dart';
Future<void> checkReachability() async {
final reachability = await Notify.manager.pushDriver.reachability();
if (reachability == PushReachability.blocked) {
// The OS will not re-prompt. Point at the platform setting instead.
}
}
Environment Variables#
The installer writes the App ID as a literal. To keep it out of source control, read it from .env with Magic's env helper:
import 'package:magic/magic.dart';
// In `notificationConfig`, under `notifications.push`.
Map<String, dynamic> pushConfig() {
final Map<String, dynamic> push = {
'app_id': env('ONESIGNAL_APP_ID'),
};
return push;
}
ONESIGNAL_APP_ID=12345678-1234-1234-1234-123456789012
notifications:doctor recognises env('KEY'), env<String>('KEY') and an envString('KEY', fallback) wrapper. It cannot read the value a build will see, so it reports the key it comes from, and warns when .env is missing or carries nothing for that key. A form it does not recognise, such as String.fromEnvironment, reads as an App ID that is not configured.
Magic loads .env as a Flutter asset; see Magic configuration.
Runtime Config Access#
After Magic boots, any value is available through Config.get:
import 'package:magic/magic.dart';
({String? appId, int? interval, bool? softPrompt}) readNotificationConfig() {
final appId = Config.get<String>('notifications.push.app_id');
final interval = Config.get<int>('notifications.database.polling_interval');
final softPrompt = Config.get<bool>('notifications.soft_prompt.enabled');
return (appId: appId, interval: interval, softPrompt: softPrompt);
}
Config.get returns null when the key is absent or has another type; use ?? for a default:
import 'package:magic/magic.dart';
bool selfTestEnabled() {
final selfTest =
Config.get<bool>('notifications.push.self_test_enabled') ?? false;
return selfTest;
}
Related Documentation#
- Installation: how the config file gets into the app.
- Service Provider: which keys
boot()reads. - CLI Reference:
configureandpublish. - Preferences: the push prompt component and every translation key the shipped widgets read.
- Shipping push in a release build: service worker settings and device reachability reports.